supabase/supabase-js

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

Opened this issue ยท 0 comments

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).