Skip to content

WebSocket Race Condition in Supabase JS Client - Node.js Only #1559

@NielsVeen

Description

@NielsVeen

Description:
The Supabase JS client has a race condition in Node.js environments where realtime subscriptions consistently timeout due to improper event handler registration timing when global.WebSocket is undefined.

Root Cause

When global.WebSocket is undefined (Node.js environments), the Supabase client falls back to the ws module but has a race condition where:

  1. WebSocket connects and server responds immediately with phx_reply
  2. Client hasn't finished setting up response handlers yet
  3. Response gets lost → TIMED_OUT after 10 seconds

Environment

  • Node.js Version: 21.1.0 (also tested with 20.x LTS)
  • Supabase JS Version: 2.36.0 (also tested with 2.38.0)
  • Operating System: macOS (Darwin 23.5.0)
  • Platform: Node.js only (browsers work fine due to native global.WebSocket)

Steps to Reproduce

  1. Create a simple Supabase client with realtime subscription:
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

const channel = supabase
  .channel("my_table")
  .on(
    "postgres_changes",
    {
      event: "*",
      schema: "public",
      table: "my_table"
    },
    (payload) => {
      console.log("Change detected:", payload)
    }
  )
  .subscribe((status, err) => {
    console.log("Status:", status)
  })
  1. Run the code
  2. Observe timeout after ~10 seconds

Expected Behavior

The WebSocket should connect successfully and subscribe to postgres changes, similar to raw WebSocket implementation.

Actual Behavior

📡 Subscription status: TIMED_OUT
❌ Connection timed out. Trying to reconnect...

Working Raw WebSocket Implementation

The same realtime endpoint works perfectly with raw WebSocket:

import WebSocket from 'ws'

const wsUrl = SUPABASE_URL.replace('https://', 'wss://') + '/realtime/v1/websocket'
const params = new URLSearchParams({
  apikey: SUPABASE_ANON_KEY,
  vsn: '1.0.0'
})

const ws = new WebSocket(wsUrl + '?' + params.toString())

ws.on('open', () => {
  console.log('✅ WebSocket connected')
  
  ws.send(JSON.stringify({
    topic: 'realtime:my_table',
    event: 'phx_join',
    payload: {
      config: {
        postgres_changes: [
          { event: '*', schema: 'public', table: 'my_table' }
        ]
      }
    },
    ref: 1
  }))
})

// This works perfectly and receives real-time updates

Debug Information

Network monitoring shows the WebSocket connection works perfectly:

  • ✅ WebSocket connects successfully
  • ✅ Sends phx_join message
  • ✅ Server responds with phx_reply status ok
  • ❌ Client doesn't receive/process the response due to race condition

Analysis shows global.WebSocket is undefined in Node.js, causing the client to use a different code path with timing issues.

Proof of Race Condition

Adding a simple WebSocket monkey patch fixes the issue:

// This fixes the race condition
const OriginalWebSocket = global.WebSocket || (await import('ws')).default;
class PatchedWebSocket extends OriginalWebSocket {
  constructor(url, protocols, options) {
    super(url, protocols, options);
  }
}
global.WebSocket = PatchedWebSocket;

// Now Supabase client works correctly
const supabase = createClient(url, key);

Additional Context

  • RLS is disabled on the target table
  • Table is properly enabled for realtime replication
  • API credentials work fine for REST operations
  • Raw WebSocket implementation works perfectly
  • Issue only occurs in Node.js environments
  • Browsers work fine due to native global.WebSocket

Workaround

Set global.WebSocket to a custom class before creating the Supabase client (see code above).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions