DEV Community

Ahmed Moaz
Ahmed Moaz

Posted on AI-assisted

Three bugs I hit building a multiplayer board game site on Supabase and Cloudflare Workers

I've been building Boardit, a free site where friends play board and party games in the browser. One person opens a table, shares a five-character room code, and everyone else joins from their own phone or laptop. It runs on Next.js (App Router), Supabase and Cloudflare Workers, and it has six games so far: Property Rush, Trivia Tavern, Ludo, Who's the Spy, Snakes & Ladders and Draw and Guess.

Most of the game logic was the easy part. These three bugs cost me the most time, and none of them showed an error.

1. void query never sends anything

I had a heartbeat that updates a row so the room list knows a room is still alive. It looked like this:

void supabase.from('rooms').update({ last_seen: now }).eq('id', roomId);
Enter fullscreen mode Exit fullscreen mode

It did nothing. No error in the console, no failed request, no row in the network tab. The reason: a Supabase (PostgREST) query builder is a lazy thenable. It only sends the HTTP request when something calls .then() on it. void doesn't, and neither does just building it.

The fix is to actually run it:

void (async () => {
  await supabase.from('rooms').update({ last_seen: now }).eq('id', roomId);
})();
Enter fullscreen mode Exit fullscreen mode

The lesson I took: when a write "isn't happening" and nothing is failing, patch window.fetch and look for the request before you read any more code.

2. Realtime row changes were slow, so I nudged over broadcast

Every live feature (game state, chat, the drawing board) subscribed to postgres_changes and also polled. On my project I measured it on 16 September 2026: a row written to a chat table reached a subscribed browser through postgres_changes after about 5 seconds. A broadcast message on the same connection arrived in about 310 ms. Mine isn't a general benchmark, just my setup, but it explained why the games felt like they were catching up: the polls were doing all the real work.

What worked: after a write, the database sends a contentless "look again" message on a private broadcast topic (realtime.send() from a trigger), and each client refetches through its own row-level security policy. Because the message carries no data, nothing secret rides the broadcast, and Postgres still decides who can see which rows. I kept a slow poll as a backstop for a dropped socket.

3. The room code is a credential, so analytics had to be locked down

In Boardit, anyone with the code can sit at the table, so the code is effectively a password. That made normal analytics settings dangerous:

  • Autocapture sends the text of whatever was clicked. The lobby shows the code as a "copy" button, so the code would have gone to the analytics vendor.
  • URLs such as /room/AB3XK/table contain it, including inside redirect parameters.
  • Heatmaps key their data by URL, which turns the code into a property name that a "clean the values" filter walks straight past.

What I ended up with: autocapture, session replay, heatmaps and dead-click capture are off in code (not just in the dashboard, because the dashboard setting can switch itself back on from remote config), room paths are rewritten to /room/[code] before anything is sent, and every event property goes through a scrubber. Third-party scripts like ads simply don't load on room pages, because you can't redact a script you didn't write.

What I'd do differently

Write the "no code in analytics" test before the first analytics event, not after the first leak. And treat any Supabase write that isn't awaited as a bug until proven otherwise.

If you want to see how it plays, it's here: playboardit.com. I'd be glad to hear what you'd build differently, especially around realtime on Supabase.

Top comments (0)