6 min read
Realtime & Edge Functions
Realtime subscriptions inherit RLS, not a separate permission system — subscription cleanup, stateless cold-start functions, and verifying the caller instead of trusting it.
Principles
Realtime subscriptions are RLS-governed, not a separate permission system
A Postgres Changes subscription only streams rows the subscribing client's RLS policies would let them `SELECT`. There's no separate realtime ACL to configure, but that also means a bad RLS policy silently broadcasts data it shouldn't.
A subscription is a resource that must be torn down
Every `channel.subscribe()` needs a matching `removeChannel()` on unmount. A component that resubscribes on every render without cleanup accumulates duplicate open connections until the tab grinds to a halt.
Edge Functions are stateless and cold-start
No in-memory cache or counter survives between invocations reliably. Anything that needs to persist belongs in the database or an external store, not a module-level variable.
Verify the JWT inside the Edge Function, don't trust the caller
An Edge Function deployed with `--no-verify-jwt`, or one that skips the auth header check, is a public function, whatever URL it happens to be served from. Check `Authorization` explicitly.
Broadcast and Presence are for ephemeral state, Postgres Changes is for durable state
Cursor positions and 'who's online' belong in Broadcast/Presence, which don't touch the database per event. Anything that must survive a refresh belongs in a table with Postgres Changes.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Subscription without cleanup
Avoid
useEffect(() => {
supabase
.channel("room-1")
.on("postgres_changes", { event: "*", schema: "public", table: "messages" }, handleChange)
.subscribe();
// No cleanup — every remount opens a new connection on top
// of the old one, which never closed.
}, []);Fast Refresh, navigation, or a parent re-render each leave behind an open channel, and the client accumulates duplicate subscriptions.
Prefer
useEffect(() => {
const channel = supabase
.channel("room-1")
.on("postgres_changes", { event: "*", schema: "public", table: "messages" }, handleChange)
.subscribe();
return () => {
supabase.removeChannel(channel); // torn down on unmount
};
}, []);Exactly one active subscription exists per mounted component, cleaned up the moment it's no longer needed.
State assumed to persist across Edge Function invocations
Avoid
// index.ts — module-level counter, assumed to survive between calls
let requestCount = 0;
Deno.serve((req) => {
requestCount++; // resets unpredictably on cold starts and scale-out
return new Response(String(requestCount));
});Edge Functions can cold-start or run across multiple isolated instances — in-memory state isn't shared or guaranteed to survive.
Prefer
Deno.serve(async (req) => {
const { data } = await supabase.rpc("increment_request_counter");
return new Response(String(data));
});The counter lives in the database, which is the actual durable, shared state store — every invocation sees the same value.
Trusting the caller instead of verifying the JWT
Avoid
// Deployed with --no-verify-jwt "just for testing," never revisited.
Deno.serve(async (req) => {
const { userId } = await req.json(); // caller-supplied, unverified
return await doSensitiveThing(userId);
});Anyone who knows the function's URL can call it as any user ID they choose to supply — there's no actual authentication happening.
Prefer
Deno.serve(async (req) => {
const authHeader = req.headers.get("Authorization");
const { data: { user }, error } = await supabase.auth.getUser(
authHeader?.replace("Bearer ", ""),
);
if (error || !user) return new Response("Unauthorized", { status: 401 });
return await doSensitiveThing(user.id); // verified identity, not caller input
});The function verifies who's calling it before trusting anything about who they claim to be.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Realtime subscriptions are covered by the same RLS review as the table itself — nothing streams that shouldn't be readable.
- Every `channel.subscribe()` has a matching `removeChannel()` on cleanup/unmount.
- Edge Functions treat their own memory as ephemeral; anything that must persist is written to the database or an external store.
- Edge Functions verify the caller's JWT explicitly, rather than relying on `--no-verify-jwt` or an unchecked header.
- Ephemeral collaborative state (cursors, presence) uses Broadcast/Presence; durable state uses Postgres Changes.
- Cold-start latency for latency-sensitive Edge Functions has actually been measured, not assumed acceptable.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/supabase-realtime-and-edge-functions/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: supabase-realtime-and-edge-functions
description: >-
Supabase Realtime and Edge Functions: RLS-governed subscriptions,
subscription cleanup, stateless cold-start Edge Functions, explicit JWT
verification, and choosing Broadcast/Presence vs Postgres Changes. Use
when adding realtime features or Edge Functions.
argument-hint: "[channel or function]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Realtime & Edge Functions
Apply when adding a realtime subscription or an Edge Function. For a subscription, confirm it's torn down on cleanup; for a function, confirm it verifies the caller instead of trusting whatever the request claims.
## Rules
1. **Realtime subscriptions are RLS-governed, not a separate permission system** — A Postgres Changes subscription only streams rows the subscribing client's RLS policies would let them `SELECT`. There's no separate realtime ACL to configure, but that also means a bad RLS policy silently broadcasts data it shouldn't.
2. **A subscription is a resource that must be torn down** — Every `channel.subscribe()` needs a matching `removeChannel()` on unmount. A component that resubscribes on every render without cleanup accumulates duplicate open connections until the tab grinds to a halt.
3. **Edge Functions are stateless and cold-start** — No in-memory cache or counter survives between invocations reliably. Anything that needs to persist belongs in the database or an external store, not a module-level variable.
4. **Verify the JWT inside the Edge Function, don't trust the caller** — An Edge Function deployed with `--no-verify-jwt`, or one that skips the auth header check, is a public function, whatever URL it happens to be served from. Check `Authorization` explicitly.
5. **Broadcast and Presence are for ephemeral state, Postgres Changes is for durable state** — Cursor positions and 'who's online' belong in Broadcast/Presence, which don't touch the database per event. Anything that must survive a refresh belongs in a table with Postgres Changes.
## Patterns
### Subscription without cleanup
**Avoid** — Fast Refresh, navigation, or a parent re-render each leave behind an open channel, and the client accumulates duplicate subscriptions.
```tsx
useEffect(() => {
supabase
.channel("room-1")
.on("postgres_changes", { event: "*", schema: "public", table: "messages" }, handleChange)
.subscribe();
// No cleanup — every remount opens a new connection on top
// of the old one, which never closed.
}, []);
```
**Prefer** — Exactly one active subscription exists per mounted component, cleaned up the moment it's no longer needed.
```tsx
useEffect(() => {
const channel = supabase
.channel("room-1")
.on("postgres_changes", { event: "*", schema: "public", table: "messages" }, handleChange)
.subscribe();
return () => {
supabase.removeChannel(channel); // torn down on unmount
};
}, []);
```
### State assumed to persist across Edge Function invocations
**Avoid** — Edge Functions can cold-start or run across multiple isolated instances — in-memory state isn't shared or guaranteed to survive.
```ts
// index.ts — module-level counter, assumed to survive between calls
let requestCount = 0;
Deno.serve((req) => {
requestCount++; // resets unpredictably on cold starts and scale-out
return new Response(String(requestCount));
});
```
**Prefer** — The counter lives in the database, which is the actual durable, shared state store — every invocation sees the same value.
```ts
Deno.serve(async (req) => {
const { data } = await supabase.rpc("increment_request_counter");
return new Response(String(data));
});
```
### Trusting the caller instead of verifying the JWT
**Avoid** — Anyone who knows the function's URL can call it as any user ID they choose to supply — there's no actual authentication happening.
```ts
// Deployed with --no-verify-jwt "just for testing," never revisited.
Deno.serve(async (req) => {
const { userId } = await req.json(); // caller-supplied, unverified
return await doSensitiveThing(userId);
});
```
**Prefer** — The function verifies who's calling it before trusting anything about who they claim to be.
```ts
Deno.serve(async (req) => {
const authHeader = req.headers.get("Authorization");
const { data: { user }, error } = await supabase.auth.getUser(
authHeader?.replace("Bearer ", ""),
);
if (error || !user) return new Response("Unauthorized", { status: 401 });
return await doSensitiveThing(user.id); // verified identity, not caller input
});
```
## Review checklist
- [ ] Realtime subscriptions are covered by the same RLS review as the table itself — nothing streams that shouldn't be readable.
- [ ] Every `channel.subscribe()` has a matching `removeChannel()` on cleanup/unmount.
- [ ] Edge Functions treat their own memory as ephemeral; anything that must persist is written to the database or an external store.
- [ ] Edge Functions verify the caller's JWT explicitly, rather than relying on `--no-verify-jwt` or an unchecked header.
- [ ] Ephemeral collaborative state (cursors, presence) uses Broadcast/Presence; durable state uses Postgres Changes.
- [ ] Cold-start latency for latency-sensitive Edge Functions has actually been measured, not assumed acceptable.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim