backend-realtime · git:20261006.39320f8 · 2026-10-06 · sha256 bd0b829a45ce516b

backend-realtime git:20261006.39320f8A

Immutable. This exact content never changes and is served at /api/v1/blob/bd0b829a45ce516b.

---
name: backend-realtime
description: >
  Implement real-time features with WebSockets, Supabase Realtime, or
  Server-Sent Events. Use when "real-time", "live updates", "WebSocket",
  "notifications", "chat", "presence", "collaborative", "live data", or
  "instant sync".
license: MIT
---

# Real-time Features Skill

**Degree of freedom: MIXED.** Which transport and event `[HIGH freedom]`;
existing-subscription probes and unmount cleanup `[LOW freedom — run exactly]`.

## How to reason

1. **Observe** — existing channels, sockets, hooks, cleanup
2. **Interpret** — new subscription vs reuse vs transport mismatch
3. **Classify** — postgres-changes / presence / broadcast / SSE / optimistic
4. **Severity** — leaked or duplicate subscription outranks a missing typing indicator

## Worked example

> **Observe:** chat mounts `useRealtimeMessages` twice; no untrack on leave; `package.json` already has `@supabase/supabase-js`.
> **Interpret:** duplicate INSERT listeners; leftover presence.
> **Classify:** one channel hook with unmount cleanup; reuse Supabase Realtime — do not add Socket.io.
> **Verify:** one `postgres_changes` subscription; `removeChannel` on unmount; no second socket lib.

## Self-critique before reporting

- **Existing first** — grepped channels/sockets before adding a library
- **Cleanup** — every subscribe has unmount `removeChannel` / `close`
- **One listener** — no duplicate subscription for the same event
- **Right owner** — FE↔BE payload mismatch → `debug-fe-be-integration`; queue/job instead of live push → `backend-patterns`

Implement live, collaborative features using WebSockets, Supabase Realtime, and Server-Sent Events.

> Code examples assume a **Next.js App Router + Supabase** stack
> (`@/lib/supabase/client`, `'use client'`). Adapt import paths and row types to
> the detected stack.

## Check existing first  [LOW freedom — run exactly]

**Before implementing ANY real-time feature, verify:**

1. **Check for existing real-time setup:**
```bash
cat package.json | grep -i "socket\|realtime\|pusher\|ably"
rg "supabase.*channel|useSubscription|WebSocket" --type ts --type tsx
```

2. **Check for existing patterns:**
```bash
rg "on\('INSERT'\|on\('UPDATE'\|subscribe\(" --type ts
ls -la src/hooks/use*Realtime* src/lib/realtime* 2>/dev/null
```

3. **Check Supabase config:**
```bash
rg "createClient|supabaseUrl" --type ts -l
grep -oh "SUPABASE_[A-Z_]*" .env* 2>/dev/null | sort -u   # names only — never print values
```

**Why:** Real-time connections are stateful. Don't create duplicate subscriptions.

## Supabase Realtime  [HIGH freedom]

### Subscribe to Database Changes

The canonical `useRealtimeMessages` hook (initial fetch + INSERT/DELETE
subscription with unmount cleanup) is in
[`references/patterns.md`](references/patterns.md).

```tsx
useEffect(() => {
  const channel = supabase
    .channel(`room:${roomId}`)
    .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
        (payload) => setMessages((prev) => [...prev, payload.new as Message]))
    .subscribe()
  return () => { supabase.removeChannel(channel) }
}, [roomId, supabase])
```

### Presence (Online Users)

- One channel per room (`presence:${roomId}`); `channel.track(currentUser)` only after `status === 'SUBSCRIBED'`.
- Read the roster from `presenceState()` on the `sync` event; `join`/`leave` are for side effects only.
- Cleanup is `channel.untrack()` then `supabase.removeChannel(channel)`.

Full `usePresence` hook: [references/patterns.md](references/patterns.md) §Presence.

### Broadcast (Custom Events)

- Hold the channel in a `useRef`; register `.on('broadcast', { event })` handlers once, then `.subscribe()`.
- Send with `channel.send({ type: 'broadcast', event, payload })`; broadcast is ephemeral, never a source of truth.
- Use for cursors and typing; use `postgres_changes` for anything that must persist.

Full `useBroadcast` hook and collaborative-cursor usage: [references/patterns.md](references/patterns.md) §Broadcast.

## TanStack Query + Real-time  [HIGH freedom]

- Keep `useQuery` as the data source; the realtime channel only calls `queryClient.invalidateQueries({ queryKey })`.
- Subscribe to `postgres_changes` with `event: '*'` on the table (optional `filter`); remove the channel on unmount.
- One hook, `useRealtimeQuery(queryKey, queryFn, table, filter?)`, so every list gets the same behaviour.

Full `useRealtimeQuery` hook: [references/patterns.md](references/patterns.md) §TanStack Query + Real-time.

## Optimistic Updates  [HIGH freedom]

- `useOptimistic(initial, reducer)` plus `useTransition`; add the optimistic row with `pending: true` and a `crypto.randomUUID()` id, then await the Server Action.
- Render pending rows at reduced opacity; the realtime INSERT or the action result replaces them.
- Disable the submit button while `isPending`.

```tsx
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
  initialMessages, (state, msg: Message) => [...state, msg]
)
startTransition(async () => {
  addOptimisticMessage({ id: crypto.randomUUID(), content, pending: true, /* ... */ })
  await addMessage(content) // Server Action
})
```

Full `Chat` component: [references/patterns.md](references/patterns.md) §Optimistic Updates.

## Server-Sent Events (SSE)  [HIGH freedom]

- Route handler returns a `ReadableStream`; write `data: ${JSON.stringify(x)}\n\n` frames with `TextEncoder`.
- Headers: `text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`; heartbeat every 30 s.
- Clean up on `request.signal` `abort` (clear timers, `controller.close()`).
- Client: `new EventSource(url)`; close in the effect cleanup and on `onerror`, then reconnect with backoff.
- Pick SSE for one-way server push without Supabase; pick Realtime when the data already lives in Postgres.

Full route handler and `useSSE` hook: [references/patterns.md](references/patterns.md) §Server-Sent Events.

## Typing Indicators  [HIGH freedom]

- Broadcast `{ event: 'typing', payload: { userId } }` on keystroke; ignore your own id on receipt.
- Add the sender to `typingUsers` and drop them after 3 s of silence; debounce sends with a `timeoutRef`.

Full `useTypingIndicator` hook: [references/patterns.md](references/patterns.md) §Typing Indicators.

## Validation  [LOW freedom — do not skip]

After implementing real-time features:

1. **Connection handling** → Reconnects on disconnect, shows status
2. **Error handling** → Graceful degradation when real-time unavailable
3. **Memory leaks** → All subscriptions cleaned up on unmount
4. **Duplicate subscriptions** → No multiple listeners for same event
5. **Optimistic updates** → UI feels instant, handles conflicts
6. **Mobile** → Works on spotty connections, battery efficient