I wanted to understand Supabase Realtime by building something small enough to see every message. First a tic-tac-toe room (Broadcast for moves, Presence for who is here), then the same idea as a live order board where the database keeps the state. Along the way I hit the two problems that matter in practice: the person who joins late sees nothing, and a connection that stalls quietly keeps saying "Connected" over stale data.
AI disclosure: I used Claude to help draft this post. I ran every step in my own project, checked the results, and edited the text. The mistakes are mine.
Repo: https://github.com/tomozilla/realtime-tictactoe-demo-en
Plain-language companion (no code, no product names): https://medium.com/p/ae4327fd4384
What you will build
- A tic-tac-toe page you play in several browser tabs (one HTML file).
- A live order board: a table plus a page that updates everyone's screen, shows who is viewing and who is looking at which card, and recovers after a disconnect.
Before you start
- Cost. Free plan, nothing billable. Per the Supabase docs (checked 2026-10-22), Free includes 2 million Realtime messages a month and 200 peak concurrent connections; Pro includes 5 million messages and 500 connections, then USD 2.50 per million messages and USD 10 per 1,000 connections. I did not check how messages are counted, so I have no per-action estimate.
- Security. Both demos are public and unauthenticated. Read "Security" at the end.
- Not verified. A tab that is really in the background, other networks and devices, many users, private channels.
1. Tic-tac-toe with Broadcast and Presence
Everyone joins the same channel. broadcast.self: false means you do not receive your own messages, and presence.key is how you appear in the roster:
const supabase = createClient(cfg.SUPABASE_URL, cfg.SUPABASE_PUBLISHABLE_KEY)
const room = supabase.channel('game-room-1', {
config: { broadcast: { self: false }, presence: { key: name } },
})
A move is a Broadcast message. Everyone else applies it:
function play(i, m, send) {
if (board[i] || winner()) return
board[i] = m; turn = m === 'X' ? 'O' : 'X'; render()
if (send) room.send({ type: 'broadcast', event: 'move', payload: { cell: i, mark: m } })
}
room.on('broadcast', { event: 'move' }, ({ payload }) => play(payload.cell, payload.mark, false))
Presence says who is here. Each tab calls track after it subscribes, and every tab redraws its roster on sync:
room.on('presence', { event: 'sync' }, () => {
const people = Object.values(room.presenceState()).flat()
// ... build "Online: N - Alice(X), Bob(O), Carol (spectator)"
})
room.subscribe(async (status) => {
if (status !== 'SUBSCRIBED') return
await room.track({ name, mark, online_at: new Date().toISOString() })
})
Run it: copy config.example.js to config.js (project URL and publishable key), then python3 -m http.server 8765 --bind 127.0.0.1 and open a tab per player:
http://127.0.0.1:8765/index.html?name=Alice&mark=Xhttp://127.0.0.1:8765/index.html?name=Bob&mark=O
When Alice clicked the center cell, it showed on Bob's board after about 111 ms (one sample, two tabs on one computer). After a few moves, Bob's tab:
Watching the messages
Under Realtime, Inspector in the dashboard, join the channel game-room-1 and press Start listening. Every join, sync, move and leave appears:
Note that a move is just a payload, {"cell": 2, "mark": "X"}, and nothing stores it.
2. The late joiner
Alice and Bob had played three moves when Carol opened ?name=Carol:
An empty board, and it even said "X's turn" while the game was waiting for O. Broadcast does not remember the past, and Carol missed the messages. The fix is for a late joiner to ask, and for a player to answer:
// A late joiner asks "what is the board now?" and a player answers
room.on('broadcast', { event: 'state-request' }, () => {
if (mark) room.send({ type: 'broadcast', event: 'state', payload: { board, turn } })
})
room.on('broadcast', { event: 'state' }, ({ payload }) => { board = payload.board; turn = payload.turn; render() })
// ... and after subscribing:
if (resync) room.send({ type: 'broadcast', event: 'state-request', payload: {} })
With &resync=1 Dave got the right board and turn:
This only works if a player is around to answer. For anything that matters, keep the real state in the database. That is part 2.
3. A live order board where the database holds the state
create table if not exists public.orders (
id bigint primary key generated always as identity,
customer text not null,
item text not null,
status text not null default 'received' check (status in ('received', 'cooking', 'done')),
updated_by text,
updated_at timestamptz not null default now()
);
alter table public.orders enable row level security;
-- * DEMO ONLY: allow reading, adding and updating without login (anon).
create policy "demo: anyone can read orders" on public.orders for select to anon, authenticated using (true);
create policy "demo: anyone can add orders" on public.orders for insert to anon, authenticated with check (true);
create policy "demo: anyone can update orders" on public.orders for update to anon, authenticated using (true) with check (true);
-- Add the table to the publication that Realtime (postgres_changes) streams from
alter publication supabase_realtime add table public.orders;
The page reads the whole table when it joins and again after any reconnect, then listens for changes:
mine
.on('postgres_changes', { event: '*', schema: 'public', table: 'orders' }, (p) => { /* update the card, add a log line */ })
.on('presence', { event: 'sync' }, () => { people = Object.values(mine.presenceState()).flat().map((m) => ({ name: m.name, focus: m.focus ?? null })) })
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') { setConn(true, status); await mine.track({ name: me, focus: focusId }); await loadAll() }
else if (['CLOSED', 'CHANNEL_ERROR', 'TIMED_OUT'].includes(status)) { setConn(false, status); scheduleReconnect() }
})
Run app/index.html the same way, with ?name=Alice and ?name=Bob. The board:
Presence also carries which card each person is touching (focus). When Bob clicked the Cheeseburger card, Alice saw a badge:
That badge took up to about 1.5 seconds to appear (it was not there at 0.5 s). A card move goes through the database. I measured six moves from a click in Bob's tab to the card showing in Alice's tab:
| Sample | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| ms | 497 | 739 | 754 | 764 | 859 | 1265 |
That is slower than the tic-tac-toe's roughly 111 ms, which is expected: this path is database change, then Realtime, then the other browser. A late joiner (Carol) saw every order the moment she arrived, because she reads the table first.
4. What went wrong
A silent stall left the page "Connected" for 46 seconds. I first tested a disconnect with supabase.realtime.disconnect() (the page exposes it with ?debug=1). That is a clean close, and the page handled it: it went grey, reconnected and reloaded within a few seconds. The case I care more about is a network that stalls without closing. I could not fake that with the browser's offline emulation: in my test the page stayed "Connected" and received the update as soon as I switched emulation off, so the open WebSocket was apparently not affected. What worked was Playwright's routeWebSocket, which lets you stop forwarding traffic without closing the socket:
let blackhole = false
await page.routeWebSocket(/realtime/, (ws) => {
const server = ws.connectToServer()
ws.onMessage((m) => { if (!blackhole) server.send(m) })
server.onMessage((m) => { if (!blackhole) ws.send(m) })
})
// later: blackhole = true -> the socket stays open but nothing flows
With Bob's traffic blocked, I moved the Margherita card in Alice's tab. Bob's tab kept showing a green "Connected" with the card in the old column:
It stayed that way for 46 seconds. Then the client noticed a missed heartbeat, the page went grey, and when traffic returned it reconnected and reloaded the table in about 3 seconds. The library's default heartbeat is 25 seconds as far as I know, and the page cannot know sooner. A one-line change shortens the window:
const supabase = createClient(cfg.SUPABASE_URL, cfg.SUPABASE_PUBLISHABLE_KEY, { realtime: { heartbeatIntervalMs: 10000 } })
Same test, new setting: the page flagged it after 16 seconds.
Sixteen seconds is still a window of stale data with a green light. I did not measure the cost of the shorter heartbeat across many clients.
The "looking" badge is not instant. About 1.5 seconds in my runs. Fine for awareness, wrong for anything that needs exclusivity.
I could not test a tab that is truly in the background. I tried, but Playwright keeps every tab "visible", so there was nothing to throttle. If your users keep the board in a background tab, test that on a real browser. The page also reconnects right away when a tab becomes visible again, but I only know that from reading my own code.
Security
- The tic-tac-toe channel is public: anyone who knows how it works can join and send fake messages. I did not try private channels.
- Win and draw detection runs only in the browser, so it cannot stop cheating. If money or points are involved, decide the result on the server.
-
orderslets anyone, with no login, read, add and update rows. Restrict it to logged-in users and narrow the rows and columns before a real use. - supabase-js is loaded with
@2(major version only). Pin it for anything real. -
config.jsis git-ignored; only the publishable key belongs there.
Cleanup
Run sql/app/99_cleanup.sql (drop table if exists public.orders cascade;, which also removes it from the publication).
Recap
- Broadcast tells everyone what just happened and keeps nothing. Presence tracks who is here.
- A late joiner sees an empty room unless something holds the state. A table is a sturdier answer than "ask a player".
- A change through the database took 497 to 1,265 ms in my runs; a Broadcast message about 111 ms.
- A stalled connection can look healthy: 46 s of green "Connected" with the default heartbeat, 16 s with 10 s.
- Everything here ran on tabs on one computer, with fake data.
The code is in the repo: https://github.com/tomozilla/realtime-tictactoe-demo-en. The plain-language version: https://medium.com/p/ae4327fd4384.
























