At 3:40 AM on a Sunday, my phone buzzed. A cron job had failed to ping the WebSocket endpoint on jeopardygithub.com.
The app is straightforward: you punch in any GitHub repository, it parses the codebase, generates a 6-category Jeopardy board with difficulty-scaled trivia, and lets you and your teammates join a multiplayer room with real-time buzzers.
The original deployment was classic developer duct tape: an Express server and the ws package running under PM2 on a single VPS. It worked, until it didn’t:
- If PM2 crashed or restarted, every active game room evaporated.
- Memory slowly leaked across long-lived WebSocket connections.
- Scaling meant introducing Redis Pub/Sub, sticky load-balancer sessions, and paying for idle CPU when nobody was playing.
WebSockets and serverless used to be fundamentally incompatible. Serverless functions are stateless and ephemeral; WebSockets demand persistent memory and real-time state synchronization.
Last week, I deleted the VPS, threw away PM2, and ported the entire stack to 100% serverless Cloudflare Workers and Durable Objects.
Here is how the architecture works and what I learned along the way.
The Architecture

The system splits into three clear boundaries:
[ Browsers & Phones ] ──HTTP/WS──> [ Cloudflare Edge Worker ]
│
┌─────────────────┴─────────────────┐
▼ ▼
[ AI Board Synthesizer ] [ GameRoom Durable Object ]
• GitHub REST API (Tree/Code) • In-memory WebSocket Hub
• OpenRouter / gpt-4o-mini • Atomic Buzzer Mutex
• Multi-tier Edge Cache • Embedded SQLite WAL
- Cloudflare Worker Gateway: Terminates HTTP and WebSockets at the edge, routes static assets via the
[assets]binding, and dispatches requests to room-specific Durable Objects. - AI Board Synthesizer: Fetches the target repository’s file tree, filters the signal from the noise, and prompts
gpt-4o-minito construct a 6x5 trivia matrix. - GameRoom Durable Object: Exactly one single-threaded coordinator per 6-character room code (
ABCDEF), backed by embedded SQLite with zero race conditions on buzzer presses.
Problem 1: The Buzzer Race Condition
In Jeopardy, whoever buzzes first gets the floor. In a traditional distributed architecture, two players buzzing within 15 milliseconds of each other requires:
- Distributed locks in Redis (
SETNX), or - Database conditional updates with optimistic concurrency (
UPDATE room WHERE buzzed_player IS NULL), or - A single beefy node server that becomes a single point of failure.
Cloudflare’s Durable Objects use an actor-based concurrency model. Each Durable Object is single-threaded and globally unique for its ID:
// worker/src/index.js
case 'buzz-in': {
// If someone already buzzed, drop immediately
if (!this.currentQuestion || this.currentQuestion.buzzedPlayerId) return;
// If this player already failed this question, lock them out
if (this.buzzerQueue.includes(player.id)) return;
// Atomic state change — no Redis lock, no SQL transaction needed
this.currentQuestion.buzzedPlayerId = player.id;
this.broadcast('buzz-result', {
playerId: player.id,
playerName: player.name
});
break;
}
Because execution within the Durable Object instance is strictly sequential, the first incoming WebSocket message wins. No mutex libraries. No lock contention. Zero race conditions by design.
Problem 2: Routing WebSockets to the Right Room
When a contestant visits jeopardygithub.com or joins room 9F2A1B, the client opens a WebSocket connection to:
wss://jeopardygithub.com/ws?gameId=9F2A1B
The edge Worker takes the 6-character room code, derives a deterministic Durable Object ID, and hands off the connection:
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === '/ws') {
const gameId = (url.searchParams.get('gameId') || 'DEFAULT').toUpperCase();
// Derive globally unique 64-character DO ID from room name
const doId = env.GAME_ROOM.idFromName(gameId);
const stub = env.GAME_ROOM.get(doId);
// Forward the WebSocket upgrade directly to the actor
return stub.fetch(request);
}
// Static assets & REST API fallback...
}
};
Cloudflare routes that connection directly to whichever data center the GameRoom actor is currently running in. If players are in Frankfurt, the DO spawns in Frankfurt.
Problem 3: Mining Trivia from Any Codebase
A good Jeopardy board needs trivia that ranges from easy ($100) to obscure ($500). Dumping an entire 50,000-line repository into an LLM context is wasteful and hits token ceilings.
Instead, the Worker scores and selects the top five most informative source files before generating the board:
// Prioritize entry points and core logic, ignore tests and vendor blobs
const scored = blobs.map(f => {
let score = 0;
const name = f.path.toLowerCase();
if (name.match(/^(index|main|app|server|mod)\./)) score += 10;
if (name.includes('src/')) score += 5;
if (name.includes('lib/')) score += 4;
if (name.includes('test') || name.includes('spec')) score -= 5;
if (f.size && f.size < 5000) score += 2;
return { ...f, score };
}).sort((a, b) => b.score - a.score);
We pull the repo metadata, languages breakdown, README, and the top 5 scored files concurrently with Promise.all().
gpt-4o-mini is given a strict schema requiring 6 categories with 5 difficulty-scaled clues each. If the AI proxy hiccups or hits a rate limit, the worker gracefully falls back to an offline AST/regex parser so the game never fails to start.
Zero-Cost Hibernation
Here is the best part: idle cost is zero.
When all players disconnect, the WebSocket connections close. Cloudflare’s runtime automatically hibernates the Durable Object and writes state to embedded SQLite.
When no one is playing, zero CPU cycles are consumed. When a developer shares a link on Twitter or Slack, the room boots up in under 50 milliseconds.
Proving It in CI with Playwright
To make sure the WebSocket handshake, buzzer locks, and scoring hold up under real network latency, I wrote automated end-to-end tests with Playwright:
// scripts/test-live-gameplay.js
test('concurrent buzzer resolution', async ({ browser }) => {
const host = await browser.newPage();
const p1 = await browser.newPage();
const p2 = await browser.newPage();
// Both players buzz within 10ms of each other
await Promise.all([
p1.click('#buzz-button'),
p2.click('#buzz-button')
]);
// Exactly one player must receive the buzzer lock
const activeBuzzer = await host.locator('.buzzer-active-name').textContent();
expect(['Player 1', 'Player 2']).toContain(activeBuzzer);
});
What I Learned
- Actors belong at the edge: Stateful coordination doesn’t belong in monolithic servers when actors can live in isolated, auto-scaling edge runtimes.
- SQLite inside Durable Objects is a game changer: Having local, zero-network-overhead SQL storage directly inside an in-memory WebSocket actor eliminates the need for separate Redis or Postgres instances.
- No more 3 AM alerts: Since moving
jeopardygithub.comto Cloudflare Workers and Durable Objects on September 1st, server maintenance has dropped to literally zero.
You can try a game on any public repo right now at jeopardygithub.com.
About Hemanth HM
Hemanth HM is a Sr. Machine Learning Manager at PayPal, Google Developer Expert, TC39 delegate, FOSS advocate, and community leader with a passion for programming, AI, and open-source contributions.