Notes from a 2 AM On-Call: Supabase Realtime Connection Reset Under Next.js SSR Load
The Failure Symptom
[supabase-js] RealtimeClient connection error: Error: WebSocket closed with code 1006 [Next.js] TypeError: Cannot read properties of undefined (reading 'channel') [Vercel] Function invocation timeout after 10s Production dashboard went dark at 1:47 AM. No client errors. Server logs showed Supabase Realtime subscriptions silently dropping after 8-12 seconds under concurrent Server Component render load. The kicker? Local dev was flawless. Staging with 10 concurrent users worked. Production with 200+ simultaneous SSR requests collapsed.
The Red Herring
Every Next.js + Supabase tutorial shows this Server Component pattern:
// app/dashboard/page.tsx import { createClient } from '@/lib/supabase/server' export default async function Dashboard() { const supabase = createClient() const channel = supabase .channel('public:events') .on('postgres_changes', { event: '*', schema: 'public', table: 'events' }, (payload) => console.log(payload) ) .subscribe() const { data } = await supabase.from('events').select() return <EventList events={data} /> } Under Next.js 15 concurrent RSC streaming, each render creates a new Supabase client instance with a fresh Realtime connection. With 50 users hitting the dashboard simultaneously, we spawned 50+ WebSocket connections to Supabase Realtime within 2 seconds. Supabase's connection pooling throttled us. Connections queued, timed out at 10s, then cascaded into retry storms.
The misleading part? createClient() in Server Components feels stateless and React-idiomatic. But Realtime WebSockets are stateful long-lived TCP connections. React's render concurrency model conflicts with connection pool limits (Supabase Free: 60 concurrent, Pro: 500).
The Root Cause & Runnable Fix
Realtime subscriptions belong in Client Components with singleton connection management, not Server Components. Server Components should handle initial data fetch only.
Fixed Architecture
1. Server Component (RSC) — Initial Data Only
// app/dashboard/page.tsx import { createClient } from '@/lib/supabase/server' import { RealtimeEvents } from './realtime-events' export default async function Dashboard() { const supabase = createClient() const { data: initialEvents } = await supabase .from('events') .select() .order('created_at', { ascending: false }) .limit(50) return <RealtimeEvents initialEvents={initialEvents} /> } 2. Client Component — Singleton Realtime Connection
// app/dashboard/realtime-events.tsx 'use client' import { createClient } from '@/lib/supabase/client' import { useEffect, useState } from 'react' import type { RealtimeChannel } from '@supabase/supabase-js' let globalChannel: RealtimeChannel | null = null export function RealtimeEvents({ initialEvents }) { const [events, setEvents] = useState(initialEvents) const supabase = createClient() useEffect(() => { if (!globalChannel) { globalChannel = supabase .channel('events-feed') .on('postgres_changes', { event: '*', schema: 'public', table: 'events' }, (payload) => { setEvents((prev) => [payload.new, ...prev].slice(0, 50)) } ) .subscribe((status) => { if (status === 'SUBSCRIBED') { console.log('[Realtime] Connected') } }) } return () => { // Keep connection alive across component remounts // Only cleanup on actual unmount (e.g., route navigation) } }, []) return ( <ul> {events.map((event) => ( <li key={event.id}>{event.title}</li> ))} </ul> ) } Key Fix: Module-level globalChannel singleton prevents duplicate subscriptions across React StrictMode double-renders and Fast Refresh. Connection count dropped from 50+ to 1 per client browser session.
Monitoring Addition
// lib/supabase/client.ts import { createBrowserClient } from '@supabase/ssr' export function createClient() { const client = createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ) client.realtime.onOpen(() => { console.log('[Realtime] WS open', new Date().toISOString()) }) client.realtime.onClose((event) => { console.error('[Realtime] WS closed', event.code, event.reason) }) return client } After deployment: zero connection resets, consistent 1-2 second subscription latency even at 500 concurrent users.
Invariant Rule
Realtime subscriptions are stateful I/O—never instantiate them in Server Components or React render loops.
If your Server Component needs live data, fetch the baseline snapshot server-side, then hydrate the Client Component with a singleton Realtime subscription. This pattern applies to any WebSocket/SSE integration (Pusher, Ably, Socket.IO) under Next.js RSC concurrency.
Connection pooling isn't just a backend database concern—it governs client WebSocket fan-out too.