Share:
Supabase Realtime in Production: What Nobody Tells You (2026)
About the Author
Nirmalraj R is a Full-Stack Developer at AgileSoftLabs, specialising in MERN Stack and mobile development, focused on building dynamic, scalable web and mobile applications.
Key Takeaways
- Supabase Realtime looks simple in demos but gets complicated in production: at thousands of connections, you hit channel limits, see stale Presence after tab visibility changes, lose Broadcasts under load, and spike database CPU from high-write tables.
- Three modes for three use cases: Postgres Changes (50–200ms, WAL-based, persistent data); Broadcast (<50ms, ephemeral, cursors/game state); Presence (CRDT-based, online tracking). Picking the wrong mode is the most common architectural mistake.
- Leaked channels are the #1 cause of hitting connection limits. Always call supabase.removeChannel(channel) on unmount and use server-side filters in Postgres Changes to reduce data volume.
- Subscribing to high-write tables (1,000+ inserts/sec) overloads Realtime. Use Postgres Changes for user-facing data (orders, messages, notifications), not analytics or logs.
- Presence has a stale-state bug after tab visibility changes. Re-track on visibilitychange with a fresh online_at timestamp to prevent ghost users.
- Supabase Pro handles up to 500 concurrent connections at $25/month. Above that, negotiate a team plan, self-host Realtime, or evaluate Ably/Pusher for high-frequency events.
- The underlying tech (Elixir/Phoenix) is production-grade, powering Discord and WhatsApp. Supabase’s managed layer adds reliability but introduces connection/message limits that production architecture must plan around.
Introduction
Supabase Realtime looks simple in demos: subscribe to a table, get live updates, ship it. In production with thousands of concurrent connections, things get complicated quickly. You hit channel limits. Presence data goes stale. Broadcasts drop under load. Database CPU spikes from listening to a high-write table that nobody thought to filter.
At AgileSoftLabs, we have deployed Supabase Realtime for applications ranging from collaborative editing tools to live sports dashboards to multi-tenant SaaS platforms. This guide covers what the documentation does not — the architectural decisions, failure modes, performance patterns, and scaling limits that only surface in real production deployments.
Cloud Development Services and Web Application Development Services build the real-time infrastructure layer — the channel architecture, presence management, and reconnection handling — that makes Supabase Realtime reliable at production scale.
Supabase Realtime Architecture
Supabase Realtime is built on the Elixir Phoenix framework, which handles WebSocket connections with exceptional efficiency — benchmarks show millions of concurrent connections on commodity hardware. The Realtime server sits between your clients and PostgreSQL as a managed intermediary.
What this means for production:
- Postgres Changes go through logical replication — there's inherent latency (~50-200ms depending on WAL processing)
- Broadcasts are direct, server-mediated — latency is typically under 50ms
- Presence is CRDT-based — eventually consistent, not strongly consistent
which has implications for how you display online user counts and handle concurrent presence updates.
The Three Realtime Modes
Understanding which mode to use for which problem is the most consequential architectural decision in any Supabase Realtime deployment:
| Mode | What It Does | Latency | Database Impact |
|---|---|---|---|
| Postgres Changes | Streams database row changes to clients | 50–200ms | Yes — WAL logical replication |
| Broadcast | Sends ephemeral messages between clients | Under 50ms | None |
| Presence | Tracks online users in a channel | Under 100ms | None |
Use Broadcast for: cursor positions, typing indicators, game state, whiteboard strokes — anything generating more than 10 events per second per user. High-frequency ephemeral data that does not need persistence belongs here.
Use Postgres Changes for: persistent data updates where data integrity matters — chat messages confirmed to the database, order status transitions, notification delivery, document saves. The 50–200ms latency is acceptable because the data has already been durably written.
Use Presence for: online user lists, "who is viewing this document," room occupancy, and collaborative editing participant tracking. The CRDT model handles concurrent join/leave events gracefully at the cost of eventual consistency.
Setting Up Channels the Right Way
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
// WRONG — subscribing to all changes on a large table
const channel = supabase.channel('all-orders')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'orders'
}, handleChange)
.subscribe();
// RIGHT — filter to only what this client needs
const channel = supabase.channel(`user-orders-${userId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'orders',
filter: `user_id=eq.${userId}` // Server-side filter — reduces data sent
}, handleChange)
.subscribe();
The filter: parameter does server-side filtering before data reaches the client — it reduces network egress, reduces Realtime server processing, and reduces the risk of accidentally broadcasting data that should be user-scoped. Always use it.
Channel naming conventions for multi-tenant apps:
// Isolate channels by tenant and room to prevent data leakage
const channelName = `tenant:${tenantId}:room:${roomId}`;
const channel = supabase.channel(channelName);
Namespace channels with the most-specific identifier first. This makes channel cleanup, debugging, and access pattern analysis significantly easier in production monitoring.
Always clean up channels on component unmount:
// In React — cleanup on unmount
useEffect(() => {
const channel = supabase.channel('my-channel')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, handler)
.subscribe();
return () => {
supabase.removeChannel(channel); // Critical — prevents connection limit exhaustion
};
}, []);
Leaked channels — channels that are created but never removed — are the number one cause of hitting Supabase's concurrent connection limits in production. A single-page application that navigates between routes without cleaning up channels will accumulate connections until the limit is reached, at which point new subscriptions silently fail.
AI Workflow Automation real-time deployment patterns follow the same channel discipline — workflow state channels are explicitly cleaned up when workflows complete or users navigate away, preventing the connection accumulation that breaks real-time notifications for other users on the same plan.
Presence: Live User Tracking
Presence tracks who is online in a channel using CRDT data structures that handle concurrent updates without conflicts:
const roomChannel = supabase.channel(`room:${roomId}`, {
config: {
presence: {
key: userId, // Unique key per client
},
},
});
roomChannel
.on('presence', { event: 'sync' }, () => {
const state = roomChannel.presenceState();
// state = { userId1: [{ online_at: '...', user_id: '...' }], ... }
setOnlineUsers(Object.keys(state));
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log(`${key} joined`);
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log(`${key} left`);
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await roomChannel.track({
user_id: userId,
username: userName,
online_at: new Date().toISOString(),
cursor: null, // Update with cursor position as needed
});
}
});
Presence Gotcha: Stale State After Tab Visibility Change
This is not documented prominently, but it affects every collaborative application. When a browser tab loses focus and regains it, the WebSocket connection may have been throttled or briefly dropped by the browser's background tab optimization. The Presence state can become stale — showing users as online when they have disconnected, or failing to show users who have rejoined.
The fix: re-track on the visibilitychange event:
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
// Re-track after tab becomes visible again with fresh timestamp
await roomChannel.track({
user_id: userId,
online_at: new Date().toISOString()
});
}
});
Broadcast: High-Frequency Events
Broadcast is designed for ephemeral, high-frequency events that do not need database persistence. It goes directly through the Realtime server with no WAL involvement, which is why it is significantly faster than Postgres Changes:
const cursorChannel = supabase.channel(`cursors:${documentId}`);
// Send cursor position — throttle to 20 events/second per client
const sendCursor = throttle((x, y) => {
cursorChannel.send({
type: 'broadcast',
event: 'cursor_move',
payload: { x, y, userId, color: userColor },
});
}, 50); // 50ms = 20 events/second max
// Receive cursor updates from other clients
cursorChannel
.on('broadcast', { event: 'cursor_move' }, ({ payload }) => {
if (payload.userId !== userId) {
updateCursorPosition(payload);
}
})
.subscribe();
By default, clients do not receive their own broadcasts. If you are building a shared whiteboard or a feature where the broadcasting client should also see its own events reflected back, enable self:
supabase.channel('my-channel', {
config: { broadcast: { self: true } }
});
Live restaurant order dashboards, sports score update feeds, and collaborative UI tools like those powering Restaurant Management Software all use Broadcast for the high-frequency kitchen-to-front-of-house event stream — the sub-50ms latency is what makes the real-time feel genuine rather than perceptibly delayed.
Postgres Changes: Database Subscriptions
// Monitor a specific row for status changes
const orderChannel = supabase.channel(`order-${orderId}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'orders',
filter: `id=eq.${orderId}`,
}, (payload) => {
const { new: updatedOrder, old: previousOrder } = payload;
updateOrderUI(updatedOrder);
})
.subscribe();
Row Level Security Applies to Postgres Changes
This is a critical security requirement that many developers discover by testing rather than by reading the documentation:
-- Without this policy, any authenticated client can subscribe
-- to changes on any row in the orders table
CREATE POLICY "Users can see their own order changes"
ON orders FOR SELECT
USING (user_id = auth.uid());
Your RLS SELECT Policy is the gate for Postgres Changes subscriptions. A client subscribing to a table without a matching RLS policy will receive an empty stream — or worse, data they should not have access to if RLS is not enabled at all. Enable RLS on every table you expose via Postgres Changes and verify your policies cover the subscription filter.
Performance Warning: Avoid High-Write Tables
Subscribing to a table that receives 1,000+ inserts per second — analytics events, application logs, sensor data streams, clickstream events — will overload the Realtime WAL processing. Use Postgres Changes only for user-facing data with moderate write volume: orders, chat messages, notifications, document saves. Route high-volume event data to a dedicated streaming system (Kafka, Redis Streams, or a time-series database) that does not create WAL pressure.
IoT Development Services: sensor data pipelines are the canonical example of this anti-pattern to avoid — IoT telemetry at scale routes through dedicated stream-processing infrastructure, not through Postgres Changes, even when the final storage destination is Supabase.
Production Scaling Limits
Supabase Pro plan limits (verify current figures at supabase.com — these change):
| Limit | Free Plan | Pro Plan | Team Plan |
|---|---|---|---|
| Concurrent connections | 200 | 500 | Custom |
| Messages per second | Limited | 2,000/s | Custom |
| Channels per connection | 100 | 100 | Custom |
| Presence users per channel | 100 | 100 | Custom |
How to Stay Within Limits
- Channel fan-out: One channel per "room" or "document" — not one channel per user pair. If 100 users are in a shared document, they all join one channel, not 4,950 peer-to-peer channels.
- Throttle broadcasts: 20–50 events per second per client maximum. Beyond this, Realtime server queues fill and messages drop.
- Clean up channels:
supabase.removeChannel(channel)on every unmount, every navigation, every modal close that had a subscription. - Filter server-side: Use
filter:in all Postgres Changes subscriptions. Unfiltered subscriptions receive all row changes on a table, multiplied by every connected subscriber. - Use connection multiplexing correctly: One Supabase client instance per browser tab. The client handles internal channel multiplexing — creating multiple clients multiplies your connection count unnecessarily.
Performance Optimization
Debounce Frequent Presence Updates
Presence updates from mouse movements without debouncing will exhaust your message quota rapidly:
const debouncedTrack = debounce(async (data) => {
await channel.track(data);
}, 200); // Maximum 5 presence updates per second
document.addEventListener('mousemove', (e) => {
debouncedTrack({ cursor: { x: e.clientX, y: e.clientY } });
});
Batch Database Updates to Reduce Realtime Events
// WRONG: update DB on every keystroke → triggers Realtime on every keystroke
onChange={(text) => {
await supabase.from('documents').update({ content: text }).eq('id', docId);
}}
// RIGHT: update DB on debounce → one Realtime event per second of inactivity
onChange={(text) => {
setLocalContent(text); // Immediate local state — no latency
debouncedSave(text); // Debounced DB write → one Realtime event per pause
}}
The debounce pattern is the most impactful single optimization for collaborative editing applications. Without it, a user typing a sentence generates 50–100 Postgres Changes events. With a 1-second debounce, that same sentence generates one event — a 98% reduction in Realtime message volume from typing activity alone.
Error Handling and Reconnection
const channel = supabase.channel('my-channel')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages'
}, handler)
.subscribe((status, error) => {
switch (status) {
case 'SUBSCRIBED':
console.log('Connected to realtime');
setRealtimeStatus('connected');
break;
case 'CHANNEL_ERROR':
console.error('Channel error:', error);
setRealtimeStatus('error');
// Supabase auto-reconnects — show UI indicator, don't implement manual retry
break;
case 'TIMED_OUT':
console.log('Connection timed out, reconnecting...');
setRealtimeStatus('reconnecting');
break;
case 'CLOSED':
console.log('Channel closed');
setRealtimeStatus('disconnected');
break;
}
});
Supabase Realtime handles reconnection automatically with exponential backoff — do not implement manual reconnection logic. The four status values cover the full connection lifecycle. Use them to drive UI state (connection indicator, offline banner, "reconnecting" spinner) rather than as triggers for reconnection code that will conflict with the client's built-in retry behavior.
Cost at Scale
Supabase pricing for Realtime (Pro plan, May 2026 — verify at supabase.com):
Real cost calculation for a typical 10,000 MAU application with 200 concurrent peak connections: the Supabase Pro plan at $25/month covers this usage. Real-time connections are included in the Pro plan; custom pricing applies above 500 concurrent.
For applications reaching 1,000 concurrent peak connections, Team plan negotiation is required. For high-scale Realtime needs above 500 concurrent, three paths are viable: negotiate a Supabase Team plan with custom connection limits; self-host the Supabase Realtime server (it is open-source) with your own scaling configuration; or use a dedicated Realtime infrastructure provider (Ably, Pusher, or a self-hosted Socket.io cluster on dedicated infrastructure) for the Realtime layer while keeping Supabase for the database.
Business AI OS multi-tenant enterprise deployments operate well above the 500 concurrent connection threshold — those architectures use self-hosted Realtime with horizontal Phoenix clustering rather than the managed Supabase plan, keeping the PostgreSQL layer on Supabase while scaling the WebSocket layer independently. Explore AgileSoftLabs case studies for real-time architecture outcomes across collaborative tools, live dashboards, and multi-tenant platforms.
Building a Real-Time Application with Supabase?
Production real-time architecture is fundamentally different from what the documentation shows in its getting-started examples. Channel management, presence state reliability, RLS enforcement, broadcast throttling, and scaling limits all require deliberate engineering decisions that the quickstart guide does not address.
AgileSoftLabs has deployed production real-time systems on Supabase for collaborative editing tools, live dashboards, and multi-tenant SaaS platforms. Explore the full products and services portfolio or contact our team to discuss your real-time architecture requirements.
Frequently Asked Questions
1. What are the real lessons from using Supabase Realtime in production in 2026?
Realtime has hard connection/message limits and pricing traps if you ignore peak usage. Bad RLS and missing indexes cause most performance issues. Use Broadcast for scalable messaging, Postgres Changes for simpler DB streams. Handle Presence reconnects carefully, and use clear retry/back‑off patterns with Edge Functions.
2. What problems do people face with Supabase Realtime in production?
High latency from server inefficiencies, network issues, and bad queries. Teams exceed connection/message limits at peak, causing throttling. Poorly indexed RLS slows subscriptions. Presence leaves a stale state on disconnects. Many misuse Broadcast vs Postgres Changes.
3. What does Supabase Realtime documentation not tell you about production?
Docs don’t show how quickly you hit limits in real apps, the performance impact of unindexed RLS under load, or peak connection planning. They under‑cover Presence management, stale connections, reconnection logic, and cost traps from scaling messages.
4. What are the common mistakes with Supabase Realtime in production?
Using Postgres Changes instead of Broadcast for high scale, not indexing RLS columns, ignoring peak connections/messages. Treating Realtime as a silver bullet, not testing under realistic load, and forgetting to monitor usage.
5. At what scale does Supabase Realtime start to struggle?
With thousands of concurrent connections and high message throughput, especially with long‑lived clients. Complex, unindexed RLS also slows queries. Issues appear when approaching plan limits, especially with inefficient patterns.
6. How do I optimize RLS for real-time performance?
Index non‑primary RLS columns, keep policies simple/selective, avoid broad TRUE on large tables. Test under realistic load, monitor performance, and adjust indexes/policies when latency spikes.
7. When should I use Broadcast instead of Postgres Changes?
Use Broadcast for scalable, low‑latency client‑to‑client messaging (chat, collaboration, live updates). Use Postgres Changes for smaller‑scale, DB‑driven updates where simplicity matters more than max scale.
8. What are the real-time pricing traps in production?
Per‑million message charges, peak connection costs, and traffic spikes during launches. Using Postgres Changes for high‑volume messaging increases costs. Scaling beyond free/Pro gets expensive without monitoring.
9. What is the real truth about Supabase Realtime in production?
Realtime is powerful but not infinite. It has connection/message limits you can hit quickly. Bad RLS can break it at scale. It’s not a silver bullet; some workloads need custom WebSocket or managed services. Success needs careful architecture and monitoring.
10. When should I not use Supabase Realtime in production?
When you need extreme scale with high concurrency/low latency and hit limits, can’t afford usage‑based pricing, have strict compliance/data‑residency needs, or require WebSocket behavior Supabase doesn’t support.
Planning a new web app? Get a free architecture review.
30 minutes with a senior engineer to pressure-test your stack, hosting, and data plan before you commit.



.png)
.png)
.png)



