DEV Community

Cover image for Notes from a 2 AM On-Call: Supabase Realtime Connection Reset Under Next.js SSR Load
Elena Rostova
Elena Rostova

Posted on

Notes from a 2 AM On-Call: Supabase Realtime Connection Reset Under Next.js SSR Load

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
Enter fullscreen mode Exit fullscreen mode

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} />
}
Enter fullscreen mode Exit fullscreen mode

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} />
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)