Testing Supabase Realtime: Subscriptions, Broadcast, and Presence
Supabase Realtime sits on top of Phoenix Channels over WebSocket. Testing it means dealing with asynchronous event delivery, connection lifecycle (SUBSCRIBED, CHANNEL_ERROR, CLOSED), and timing-sensitive state — exactly the conditions that produce flaky tests. This guide covers unit testing with a mocked Realtime client, integration testing postgres_changes events against a local Supabase instance, broadcast and presence state testing, and the patterns that eliminate flakiness.
Key Takeaways
Never use fixed setTimeout delays in Realtime tests. Use explicit promise-based barriers — resolve a promise inside the event callback. Fixed delays make tests slow on fast machines and flaky on slow ones.
Test the channel state machine, not just payloads. A subscription can be in SUBSCRIBED, CHANNEL_ERROR, or TIMED_OUT states. Tests that only assert on received payloads miss the failure modes users actually encounter.
postgres_changes tests need a database write to trigger them. Use the admin client (service role) to perform INSERT/UPDATE/DELETE after the subscription is confirmed SUBSCRIBED. Writing before the subscription is ready produces a race condition.
Mock the Realtime client at the channel factory level. The supabase-js channel() method is the right boundary. Mock it to return a fake channel that lets you push events synchronously in unit tests.
Presence state is eventually consistent. presenceState() reflects joined users; test it after all clients have called track() and received their sync events, not immediately after track.
Supabase Realtime is one of the harder parts of the Supabase stack to test well. Events arrive asynchronously over WebSocket, subscriptions have multi-step lifecycle states, and presence state must converge across multiple clients before you can assert on it. Tests written naively — fire a database write, immediately assert on received events — will fail intermittently in CI and pass locally, which is the worst possible outcome.
This guide builds the testing patterns from the ground up: how to mock the Realtime client for fast unit tests, how to write reliable integration tests against local Supabase, and how to handle the race conditions that make Realtime tests flaky.
The Channel Lifecycle You Need to Understand
Before writing tests, understand what you are actually asserting on. When you call supabase.channel('name').subscribe(), the channel goes through these states:
CLOSED → JOINING → SUBSCRIBED
↓
CHANNEL_ERROR
↓
TIMED_OUTThe subscribe() callback receives these states as the first argument. Tests that write to the database without waiting for SUBSCRIBED introduce a race: the write arrives before the subscription is ready and the event is never delivered to your test. This is the single most common source of Realtime test flakiness.
Integration Test Setup
# Start local Supabase (includes Realtime server)
supabase start
<span class="hljs-comment"># Verify Realtime is running
curl http://localhost:54321/realtime/v1/api/tenantsInstall dependencies:
npm install -D vitest @supabase/supabase-jsShared test helpers:
// tests/helpers/supabase.ts
import { createClient, SupabaseClient, RealtimeChannel } from '@supabase/supabase-js'
export function createAnonClient(): SupabaseClient {
return createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
)
}
export function createAdminClient(): SupabaseClient {
return createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
}
/**
* Wait for a channel to reach SUBSCRIBED state.
* Rejects if it reaches CHANNEL_ERROR or times out.
*/
export function waitForSubscribed(
channel: RealtimeChannel,
timeoutMs = 5000
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('Channel subscription timed out')),
timeoutMs
)
channel.subscribe((status, err) => {
if (status === 'SUBSCRIBED') {
clearTimeout(timer)
resolve()
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
clearTimeout(timer)
reject(err ?? new Error(`Channel reached state: ${status}`))
}
})
})
}
/**
* Wait for N events to arrive, or reject after timeoutMs.
*/
export function collectEvents<T>(
count: number,
timeoutMs = 5000
): { events: T[]; onEvent: (payload: T) => void; done: Promise<T[]> } {
const events: T[] = []
let resolve: (v: T[]) => void
let reject: (e: Error) => void
const done = new Promise<T[]>((res, rej) => {
resolve = res
reject = rej
})
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for ${count} events, got ${events.length}`)),
timeoutMs
)
const onEvent = (payload: T) => {
events.push(payload)
if (events.length >= count) {
clearTimeout(timer)
resolve(events)
}
}
return { events, onEvent, done }
}Testing postgres_changes Subscriptions
The pattern: create the subscription, wait for SUBSCRIBED, then perform the database write, then wait for the event promise.
// tests/realtime/postgres-changes.test.ts
import { describe, it, expect, afterEach } from 'vitest'
import {
createAnonClient,
createAdminClient,
waitForSubscribed,
collectEvents,
} from '../helpers/supabase'
describe('postgres_changes — messages table', () => {
const channels: ReturnType<typeof createAnonClient>['channel'][] = []
afterEach(async () => {
// Remove all channels created during the test
await Promise.all(channels.map((ch) => (ch as any).unsubscribe()))
})
it('receives INSERT event after a row is inserted', async () => {
const client = createAnonClient()
const admin = createAdminClient()
const { onEvent, done } = collectEvents<any>(1)
const channel = client
.channel('messages-insert-test')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
onEvent
)
channels.push(channel as any)
await waitForSubscribed(channel)
// Trigger the event AFTER subscription is confirmed
await admin.from('messages').insert({ content: 'hello realtime', user_id: 'test-uid' })
const events = await done
expect(events).toHaveLength(1)
expect(events[0].new.content).toBe('hello realtime')
expect(events[0].eventType).toBe('INSERT')
})
it('receives UPDATE event with old and new values', async () => {
const admin = createAdminClient()
const { data: inserted } = await admin
.from('messages')
.insert({ content: 'original', user_id: 'test-uid' })
.select()
.single()
const client = createAnonClient()
const { onEvent, done } = collectEvents<any>(1)
const channel = client
.channel('messages-update-test')
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'messages' },
onEvent
)
channels.push(channel as any)
await waitForSubscribed(channel)
await admin.from('messages').update({ content: 'updated' }).eq('id', inserted.id)
const events = await done
expect(events[0].eventType).toBe('UPDATE')
expect(events[0].new.content).toBe('updated')
// old values are available when REPLICA IDENTITY is set to FULL on the table
// expect(events[0].old.content).toBe('original')
})
it('receives DELETE event when a row is removed', async () => {
const admin = createAdminClient()
const { data: inserted } = await admin
.from('messages')
.insert({ content: 'to-delete', user_id: 'test-uid' })
.select()
.single()
const client = createAnonClient()
const { onEvent, done } = collectEvents<any>(1)
const channel = client
.channel('messages-delete-test')
.on(
'postgres_changes',
{ event: 'DELETE', schema: 'public', table: 'messages' },
onEvent
)
channels.push(channel as any)
await waitForSubscribed(channel)
await admin.from('messages').delete().eq('id', inserted.id)
const events = await done
expect(events[0].eventType).toBe('DELETE')
expect(events[0].old.id).toBe(inserted.id)
})
it('filters events by column value using filter option', async () => {
const admin = createAdminClient()
const client = createAnonClient()
const received: any[] = []
const { onEvent, done } = collectEvents<any>(1)
const channel = client
.channel('messages-filter-test')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: 'user_id=eq.watched-user',
},
(payload) => {
received.push(payload)
onEvent(payload)
}
)
channels.push(channel as any)
await waitForSubscribed(channel)
// Insert for a different user — should NOT arrive
await admin.from('messages').insert({ content: 'not mine', user_id: 'other-user' })
// Insert for the watched user — SHOULD arrive
await admin.from('messages').insert({ content: 'mine', user_id: 'watched-user' })
const events = await done
expect(events).toHaveLength(1)
expect(events[0].new.user_id).toBe('watched-user')
})
})Testing Broadcast Channels
Broadcast does not touch the database — it is a pub/sub layer over the Realtime WebSocket. Testing it requires two clients on the same channel: one sends, one receives.
// tests/realtime/broadcast.test.ts
import { describe, it, expect, afterEach } from 'vitest'
import { createAnonClient, waitForSubscribed, collectEvents } from '../helpers/supabase'
describe('broadcast channel', () => {
it('receiver gets messages sent by sender on the same channel', async () => {
const sender = createAnonClient()
const receiver = createAnonClient()
const channelName = `broadcast-test-${Date.now()}`
const { onEvent, done } = collectEvents<any>(2)
const receiverChannel = receiver
.channel(channelName)
.on('broadcast', { event: 'cursor-move' }, onEvent)
const senderChannel = sender.channel(channelName)
await waitForSubscribed(receiverChannel)
await waitForSubscribed(senderChannel)
await senderChannel.send({
type: 'broadcast',
event: 'cursor-move',
payload: { x: 100, y: 200, userId: 'user-1' },
})
await senderChannel.send({
type: 'broadcast',
event: 'cursor-move',
payload: { x: 150, y: 250, userId: 'user-1' },
})
const events = await done
expect(events[0].payload.x).toBe(100)
expect(events[1].payload.x).toBe(150)
await receiverChannel.unsubscribe()
await senderChannel.unsubscribe()
})
it('broadcast with self-send receives own messages when selfBroadcast is enabled', async () => {
const client = createAnonClient()
const channelName = `self-broadcast-${Date.now()}`
const { onEvent, done } = collectEvents<any>(1)
const channel = client
.channel(channelName, { config: { broadcast: { self: true } } })
.on('broadcast', { event: 'ping' }, onEvent)
await waitForSubscribed(channel)
await channel.send({ type: 'broadcast', event: 'ping', payload: { ts: Date.now() } })
const events = await done
expect(events[0].payload).toHaveProperty('ts')
await channel.unsubscribe()
})
})Testing Presence State
Presence tracks which clients are currently connected to a channel. Each client calls track() with a state payload; the channel aggregates all tracked states into a presenceState() map.
// tests/realtime/presence.test.ts
import { describe, it, expect } from 'vitest'
import { createAnonClient, waitForSubscribed } from '../helpers/supabase'
/**
* Wait until presenceState contains at least `count` tracked users.
*/
function waitForPresenceCount(
channel: ReturnType<ReturnType<typeof createAnonClient>['channel']>,
count: number,
timeoutMs = 5000
): Promise<Record<string, any[]>> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for ${count} presence entries`)),
timeoutMs
)
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const userCount = Object.keys(state).length
if (userCount >= count) {
clearTimeout(timer)
resolve(state)
}
})
})
}
describe('presence state', () => {
it('tracks multiple users and reflects their state', async () => {
const channelName = `presence-test-${Date.now()}`
const clientA = createAnonClient()
const clientB = createAnonClient()
const channelA = clientA.channel(channelName)
const channelB = clientB.channel(channelName)
// Set up presence sync listener BEFORE subscribing
const presenceReady = waitForPresenceCount(channelA, 2)
await waitForSubscribed(channelA)
await waitForSubscribed(channelB)
await channelA.track({ userId: 'user-a', name: 'Alice', online_at: new Date().toISOString() })
await channelB.track({ userId: 'user-b', name: 'Bob', online_at: new Date().toISOString() })
const state = await presenceReady
const presences = Object.values(state).flat()
const names = presences.map((p: any) => p.name).sort()
expect(names).toContain('Alice')
expect(names).toContain('Bob')
await channelA.unsubscribe()
await channelB.unsubscribe()
})
it('removes user from presence state after unsubscribe', async () => {
const channelName = `presence-leave-${Date.now()}`
const clientA = createAnonClient()
const clientB = createAnonClient()
const channelA = clientA.channel(channelName)
const channelB = clientB.channel(channelName)
const twoPresent = waitForPresenceCount(channelA, 2)
await waitForSubscribed(channelA)
await waitForSubscribed(channelB)
await channelA.track({ userId: 'user-a' })
await channelB.track({ userId: 'user-b' })
await twoPresent
// Now wait for presence to drop to 1 after B leaves
const onePresent = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timed out waiting for leave event')), 5000)
channelA.on('presence', { event: 'leave' }, () => {
const count = Object.keys(channelA.presenceState()).length
if (count === 1) {
clearTimeout(timer)
resolve()
}
})
})
await channelB.unsubscribe()
await onePresent
const remaining = Object.values(channelA.presenceState()).flat() as any[]
expect(remaining).toHaveLength(1)
expect(remaining[0].userId).toBe('user-a')
await channelA.unsubscribe()
})
})Unit Testing with a Mocked Realtime Client
For component-level unit tests you do not want WebSocket connections. Mock the channel at the factory level:
// tests/__mocks__/supabase-realtime.ts
import { vi } from 'vitest'
import type { RealtimeChannel } from '@supabase/supabase-js'
type EventHandler = (payload: unknown) => void
export class MockChannel {
private handlers: Map<string, EventHandler[]> = new Map()
private subscribeCallback?: (status: string) => void
on(type: string, filter: unknown, handler: EventHandler) {
const key = `${type}`
if (!this.handlers.has(key)) this.handlers.set(key, [])
this.handlers.get(key)!.push(handler)
return this
}
subscribe(callback?: (status: string) => void) {
this.subscribeCallback = callback
// Immediately call SUBSCRIBED in tests
queueMicrotask(() => callback?.('SUBSCRIBED'))
return this
}
unsubscribe() {
return Promise.resolve()
}
send(payload: unknown) {
return Promise.resolve({ status: 'ok' })
}
// Test helper: push a fake event to all handlers registered for a type
simulateEvent(type: string, payload: unknown) {
const handlers = this.handlers.get(type) ?? []
handlers.forEach((h) => h(payload))
}
}
export const mockChannel = new MockChannel()
export const supabase = {
channel: vi.fn(() => mockChannel),
removeChannel: vi.fn(),
}
export const createClient = vi.fn(() => supabase)Unit test that uses the mock:
// tests/components/CollaborativeCursor.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mockChannel, supabase } from '../__mocks__/supabase-realtime'
vi.mock('@/lib/supabase', () => import('../__mocks__/supabase-realtime'))
import { useCursorSync } from '@/hooks/useCursorSync'
describe('useCursorSync', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('subscribes to broadcast events on mount', async () => {
const { cursors } = useCursorSync('room-1')
// Give the subscription microtask time to resolve
await Promise.resolve()
expect(supabase.channel).toHaveBeenCalledWith('room-1', expect.any(Object))
})
it('updates cursor positions when broadcast event arrives', async () => {
const { cursors } = useCursorSync('room-1')
await Promise.resolve()
mockChannel.simulateEvent('broadcast', {
event: 'cursor-move',
payload: { userId: 'user-x', x: 300, y: 400 },
})
expect(cursors.get('user-x')).toEqual({ x: 300, y: 400 })
})
})Connection Lifecycle and Error Testing
Test what your app does when the Realtime connection fails or times out:
// tests/realtime/connection-lifecycle.test.ts
import { describe, it, expect } from 'vitest'
import { createAnonClient } from '../helpers/supabase'
describe('channel error handling', () => {
it('subscribe callback receives CHANNEL_ERROR on invalid filter', async () => {
const client = createAnonClient()
const status = await new Promise<string>((resolve) => {
const channel = client
.channel('error-test')
.on(
'postgres_changes',
// Invalid filter syntax deliberately triggers a server error
{ event: 'INSERT', schema: 'public', table: 'nonexistent_table_xyz' },
() => {}
)
.subscribe((s) => {
if (s === 'CHANNEL_ERROR' || s === 'SUBSCRIBED' || s === 'TIMED_OUT') {
channel.unsubscribe()
resolve(s)
}
})
})
// The channel may subscribe successfully (filter errors are server-side)
// What matters is that the callback fires — test your error handling code path
expect(['SUBSCRIBED', 'CHANNEL_ERROR', 'TIMED_OUT']).toContain(status)
})
})Preventing Flaky Tests — Checklist
Realtime tests are the most commonly flaky tests in a Supabase project. Apply these rules consistently:
Always wait for SUBSCRIBED before writing to the database. Use the waitForSubscribed helper shown above. The callback-based approach is the only reliable one — do not replace it with a fixed delay.
Use unique channel names per test. Channel names are global within a Supabase project. If two tests use the same name and one does not clean up properly, events from one test leak into another. ${channelName}-${Date.now()} is the minimum; add a random suffix for parallel test runs.
Clean up channels in afterEach, not in the test body. If a test fails midway, cleanup in the test body is skipped. Always unsubscribe in afterEach or use afterAll for shared channels.
Use collectEvents instead of mutation + delay. The collectEvents helper resolves a promise exactly when the right number of events arrive — no polling, no sleeping. This makes tests both faster and more deterministic.
Isolate integration tests to run serially. Realtime integration tests can interfere with each other if they share tables and run in parallel. Run them with pool: 'forks', poolOptions: { forks: { singleFork: true } } in Vitest, or use --runInBand in Jest.
Do not test timing — test outcomes. Avoid assertions like "event arrived within 500ms". Assert that the event arrived with the correct payload. Timing varies by machine; payloads do not.
Vitest Config for Realtime Tests
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
envFiles: ['.env.test'],
pool: 'forks',
poolOptions: { forks: { singleFork: true } },
testTimeout: 20000, // WebSocket setup in Docker adds latency
hookTimeout: 10000,
},
})What Automation Covers That Manual Testing Misses
Realtime behavior changes silently. A schema migration that adds a column can affect postgres_changes payloads. A Supabase version upgrade can alter the presence sync protocol. A network partition in your infrastructure can leave channels in CHANNEL_ERROR without reconnecting.
HelpMeTest monitors these continuously — running the subscription lifecycle, checking that events arrive end-to-end from a database write through to the client, and alerting when a Realtime flow breaks in your staging or production environment. Unlike unit tests that mock the WebSocket layer, health monitoring runs the real stack. For teams building collaborative features or live dashboards where Realtime is a core user experience, this kind of always-on verification is what catches regressions before your users do — at $100/month with zero test maintenance on your side.
The patterns in this guide give you a test suite that is fast enough to run in CI and reliable enough to trust. Pair them with continuous monitoring and you have full coverage of the Realtime layer.