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.
Top comments (0)