All topics
Supabase

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

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.
}, []);

Fast Refresh, navigation, or a parent re-render each leave behind an open channel, and the client accumulates duplicate subscriptions.

Prefer

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
  };
}, []);

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

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));
});

Edge Functions can cold-start or run across multiple isolated instances — in-memory state isn't shared or guaranteed to survive.

Prefer

ts
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

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);
});

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

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
});

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.

Download
.claude/skills/supabase-realtime-and-edge-functions/SKILL.md

Drop 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