Real-Time with Supabase: Architecture and Gotchas
Architecture Patterns — Part 5 of 30
It's 11 PM on a Tuesday. A developer I'll call Priya messages me in a panic. Her logistics dashboard — the one her team just shipped to 40 dispatchers and 200 truck drivers — is showing stale delivery statuses. Drivers mark packages as delivered; the dashboard doesn't update. She's using postgres_changes and the subscription is definitely active. The events are just... gone.
I've seen this exact failure mode a dozen times. And the fix isn't a bug hunt — it's an architecture decision that was made wrong three weeks earlier, before the first line of code was written.
This article is about how to make that decision correctly the first time.
The Three-Mode Mental Model
Supabase Realtime gives you three distinct primitives. Most developers learn one, reach for it everywhere, and hit a wall:
Broadcast — Low-latency pub/sub. Clients publish ephemeral messages to named channels; subscribers receive them. Nothing touches the database. Fire-and-forget.
Presence — Tracks which clients are connected to a channel, with shared state. Think online indicators, cursor positions, "who's watching this document right now."
Postgres Changes — Listens to actual database writes via Postgres logical replication (WAL). Events are pushed to subscribed clients with full row data. Powerful, but carries architectural weight.
The most common mistake I see: using Postgres Changes for everything because it feels complete. You're watching the actual database. How could anything be missed? The answer is in the plumbing.
How Postgres Changes Actually Works
When you subscribe to postgres_changes, Supabase's Realtime server connects to your Postgres instance via a logical replication slot and reads the Write-Ahead Log (WAL). Each change — INSERT, UPDATE, DELETE — gets decoded from the WAL and shipped to subscribers.
Here's the architectural constraint that bites people: this pipeline runs on a single thread to preserve change order. That's not a bug, it's a deliberate tradeoff. Your events will arrive in the correct sequence. But under high write load, the WAL backlog grows and latency climbs. In production testing, teams consistently see 200–400ms latency at moderate load (2–3 writes/sec) and 500ms–1s delays at hundreds of writes/sec.
The second constraint is the RLS tax. For every change event, Supabase must verify whether each subscribed user is authorized to see that row. One INSERT with 100 concurrent subscribers means 100 authorization reads against your database. The official docs are explicit about this: compute upgrades don't help here. You're paying a per-subscriber, per-event database cost.
This is why Priya's dashboard was failing under load. Forty dispatchers, 200 drivers, all subscribed to the same deliveries table updates. At 300 events per minute, the database was running 300 × 240 = 72,000 authorization reads per minute. The WAL backlog accumulated. Events were dropped.
The Decision Framework
Before you write any subscription code, ask four questions:
1. Does this data need to persist?
If the answer is no — cursor positions, typing indicators, GPS pings that only matter right now — use Broadcast. It's faster (50–120ms vs 200–400ms), doesn't touch the database, and scales horizontally. Supabase's own benchmarks show Broadcast handling 250,000 concurrent users at over 800,000 messages/sec.
If the answer is yes — order status changes, inventory counts, user settings — the data belongs in the database, and Postgres Changes is the honest choice.
2. How many concurrent subscribers?
The Supabase docs recommend switching to Broadcast at roughly 1,000 concurrent subscribers. Below that, Postgres Changes handles the load reasonably. Above it, the authorization overhead becomes your bottleneck — not your network, not your application code.
3. How high is your write frequency?
For a feature like a delivery status feed (maybe 2–3 status changes per driver per shift), Postgres Changes is clean and appropriate. For a feature like live GPS tracking at 1 ping per 10 seconds across 200 drivers, that's 20 events/second — better handled as Broadcast from the mobile app, with periodic database persistence once per minute for history.
4. Do you need guaranteed delivery?
If the honest answer is yes, Supabase Realtime is the wrong tool — or it needs to be paired with a reconciliation strategy. Supabase is explicit: the server does not guarantee delivery. Messages are broadcast once. If a client's connection drops and reconnects, any changes that fired during the gap are gone from the stream. You need to re-fetch current state on reconnect.
The Pattern That Actually Scales
After working through several production incidents like Priya's, I now recommend a hybrid architecture for any feature with more than a handful of concurrent subscribers:
Server-side Postgres Changes → server re-broadcast to clients
Instead of every client subscribing directly to postgres_changes, you run a single server-side process that holds one Postgres Changes subscription. When changes arrive, your server validates them, applies any transformation logic, and re-broadcasts to clients via named Broadcast channels.
This collapses the RLS tax from N subscribers × M events to 1 × M events. Clients get the speed of Broadcast. You retain the reliability of WAL-sourced data.
Here's what the Postgres trigger + broadcast pattern looks like:
-- Create a function that broadcasts on every write
CREATE OR REPLACE FUNCTION public.delivery_changes()
RETURNS trigger
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'delivery:' || COALESCE(NEW.id, OLD.id)::text,
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;
-- Attach to your table
CREATE TRIGGER on_delivery_change
AFTER INSERT OR UPDATE OR DELETE ON deliveries
FOR EACH ROW EXECUTE FUNCTION public.delivery_changes();
Now on the client, instead of subscribing to postgres_changes, you subscribe to a Broadcast channel:
const channel = supabase
.channel(`delivery:${deliveryId}`)
.on('broadcast', { event: 'INSERT' }, (payload) => {
console.log('New delivery:', payload)
})
.on('broadcast', { event: 'UPDATE' }, (payload) => {
setDeliveryStatus(payload.new)
})
.subscribe()
And always add a reconnect handler to re-fetch state:
channel.on('system', {}, (payload) => {
if (payload.event === 'reconnected') {
// Fetch current state from DB — don't trust the stream for missed events
fetchCurrentDeliveryState(deliveryId)
}
})
The Gotchas That Will Bite You
DELETE events can't be filtered. The WHERE-clause filtering you can attach to INSERT and UPDATE subscriptions doesn't apply to DELETE. If you need to filter deletes, you're either adding soft deletes to your schema (an is_deleted boolean) or using the trigger-based broadcast pattern above.
REPLICA IDENTITY and old record data. By default, when a row is updated or deleted, Postgres Changes only sends you the new values. If your UI needs to know what changed, you need to set ALTER TABLE your_table REPLICA IDENTITY FULL. Be aware: this increases WAL write size for every row change on that table. For high-write tables, this can meaningfully increase storage and replication lag.
Token refresh is manual. Supabase's auth tokens expire. The JS client auto-refreshes session tokens, but Realtime's WebSocket connection doesn't automatically pick them up. You need to explicitly call supabase.realtime.setAuth(freshToken) when your JWT refreshes, or long-running subscriptions will silently fail RLS checks.
The free tier will surprise you in demos. The free plan caps concurrent connections at 200 and messages/second at 100. A demo with 10 people each with 5 browser tabs open hits the connection limit. Plan limits are hard stops, not graceful degradations — the channel join is refused, and the error shows up in WebSocket messages, not application logs.
Presence keys have a hidden 10-item limit. The presence payload object is limited to 10 keys regardless of plan. If you're syncing complex collaborative state via Presence, you'll need to normalize your payload or route through Broadcast instead.
What Supabase Realtime Is Not
A March 2026 incident thread on GitHub described intermittent subscribe failures after idle periods in self-hosted Supabase deployments — traced back to replication slots dropping during inactivity. This is a production reality: the WAL replication slot that powers Postgres Changes is a live database connection. If your Realtime server restarts, reconnects take a moment. During that window, changes are missed.
Supabase Realtime is excellent for collaborative features, live dashboards, and presence indicators where near-real-time is good enough and the occasional missed event can be recovered with a re-fetch. It is not a message queue. It doesn't provide delivery acknowledgments. It doesn't replay missed events.
If your feature requires guaranteed delivery, look at Supabase + a transactional outbox pattern, or consider a purpose-built event broker. The architecture should match the guarantee you're promising users.
Action Checklist
- Map each real-time feature to the correct primitive: Broadcast (ephemeral/high-frequency), Postgres Changes (low-to-moderate frequency, persistence required), or Presence (connected users/shared state)
- Count expected concurrent subscribers before choosing Postgres Changes; if > 1,000, plan for Broadcast re-streaming
- Add reconnect handlers to every subscription that re-fetches current state from the database
- Enable
REPLICA IDENTITY FULLonly on tables where you need old record data, and benchmark WAL size impact - Implement
setAuth()refresh in your session lifecycle to keep long-running subscriptions authorized - Verify plan limits against your expected peak concurrent connection count before launch — not after
- For DELETE filtering requirements, implement soft deletes or use the trigger-based broadcast pattern
- Test subscription behavior on mobile with backgrounded apps — connections drop and state goes stale
Ask The Guild
Priya ended up implementing the trigger-based broadcast pattern. Seven months later, her dashboard handles 240 concurrent connections, pushes 200+ GPS pings per second, and delivers status updates within 200ms of the database write. Zero WebSocket server maintenance.
What's the most creative (or painful) real-time pattern you've built on Supabase? Have you hit the RLS authorization bottleneck and solved it a different way? Are you using the server-side re-broadcast pattern, or did you route around it entirely? Share your approach in the Guild — the most interesting architectural alternatives often become the next deep-dive.
Tom Hundley is a software architect with 25 years of experience. He teaches at the AI Coding Guild and consults with teams building production systems on modern cloud-native stacks.