← Farboard

One Worker, one small database per game, and a free plan that cannot bill you

What Farboard's online play is made of, and why it is built this way: the problem a relay solves, the Cloudflare pieces it uses, how two players meet without anyone introducing them, and what a game costs here and on AWS or Vercel. Implementation shows how it is coded.

Workers Durable Objects (SQLite) WebSocket Hibernation End-to-end encrypted Default cap: 100 new games a day
The problem

Two browsers cannot simply call each other

A web page can make outgoing connections, but it cannot listen on a port, and most devices sit behind a home router or a mobile network (NAT) that blocks incoming connections anyway. So when two players are in different places, neither can reach the other directly.

The usual answer is a relay: a server with a public address that both players connect out to. Whatever one player sends, the relay passes to the other. The relay needs two properties:

On a traditional host you would run one server process and point everyone at it. Farboard does not run a server at all. It uses Cloudflare, where the "meeting point" is a built-in feature, the Durable Object.

Cloudflare in four ideas

All the Cloudflare you need for this page

Cloudflare runs data centres in hundreds of cities. A request from a player goes to the one nearest them, which is called the edge. Four features matter here, and all of them are set up by one file, wrangler.jsonc, at the top of the repository (shown in Implementation).

1. Workers: code without a server

A Worker is a JavaScript module that exports a fetch(request, env) function. Cloudflare calls it for each HTTP request, in whichever data centre received the request, and it returns a Response. There is no process for you to start or keep running. Farboard's Worker is relay/src/index.js.

Like an Express route handler, but deployed everywhere at once and started on demand.

Workers docs

2. Static assets: the website itself

"assets": { "directory": "./public" } tells Cloudflare to serve the files in public/ directly from the edge. Worker code runs only when no file matches, which here means the /v1/… routes. One deployment is therefore both the chess app and its relay, at one address.

Like a CDN in front of your app, with your app as the fallback.

Static assets docs

3. Durable Objects: one named instance, worldwide

A Worker keeps nothing between requests, and two requests may run in two different cities. A Durable Object (DO) is an instance of a JavaScript class that you address by name. Cloudflare guarantees that at most one instance with a given name is running anywhere in the world, and every request for that name is routed to it. It handles one event at a time and has its own small private database.

Like a singleton object per key that stays reachable over the network, with storage attached.

What are Durable Objects?

4. WebSocket Hibernation: idle connections cost nothing

A Durable Object can accept WebSockets. With the Hibernation API, Cloudflare keeps the connections open at its edge but can take the object out of memory between messages. The next message wakes it up again. A chess game spends nearly all its time waiting for someone to think, so it spends nearly all its time hibernating, and hibernation is not billed.

Like a server that is started for each message, while the sockets never notice.

WebSockets and hibernation

The whole system

What talks to what

Those four pieces make up one Cloudflare Worker named farboard. Files in public/ are served straight from Cloudflare's edge without running any code. Any other request runs the Worker's own code, in relay/src/index.js. That code answers the four /v1/ routes and returns a 404 for everything else, such as a mistyped address or a bot probing for common paths. Each game gets its own Durable Object: a small, single-threaded server with its own database, which both players' WebSockets connect to.

Farboard architecture Two players' browsers load the page from Cloudflare's static assets and open WebSockets to the Worker, which forwards each game to its own Room Durable Object. A single Budget Durable Object counts new games. PLAYERS Host's browser plays White or Black holds the game state and the AES-GCM key Guest's browser opened the invite link or QR code; the secret in its #fragment never reaches a server CLOUDFLARE · ONE WORKER · FREE PLAN Static assets public/ · HTML, CSS, JS Served at the edge. Free and not counted. Worker (the relay's door) relay/src/index.js GET /v1/info GET /v1/stats POST /v1/verify GET /v1/rooms/:id (WS) Room · one per game relay/src/room.js Durable Object with SQLite. Stores 2 seat-token hashes, today's message count and a 7-day clean-up alarm. Hibernates between moves. Budget · one per relay relay/src/budget.js new games today, per network page WSS sealed frames stats claim a new game

The GitHub Pages copy (emrahman.github.io/Farboard) serves the same page but has no relay of its own. public/js/config.js points it at the Cloudflare relay, which lists that address in ALLOWED_ORIGINS.

The meeting point

Same name, same object, wherever the players are

This is the whole trick. Both players connect to /v1/rooms/<roomId>. Each connection reaches the Worker at the data centre nearest that player, and each Worker asks for the Room named roomId. The name is the same, so Cloudflare sends both connections to the same single instance. Once there, passing a move from one player to the other is one send() call: no message broker, and no lookup of where the other player is connected. Because the object handles one event at a time, it also settles races such as two people opening the same invite at once.

Two players meet in one Durable Object A player in London connects to the Worker at the London edge and a player in Tokyo connects to the Worker at the Tokyo edge. Both Workers call idFromName with the same room id and reach the same single Room Durable Object, which forwards messages between the two sockets. Host, London derived roomId Xq3…f9A from the invite secret Guest, Tokyo derived roomId Xq3…f9A from the same secret CLOUDFLARE Worker · London edge relay/src/index.js checks origin and room id Worker · Tokyo edge relay/src/index.js same code, other city Room "Xq3…f9A" · the only one relay/src/room.js seat 0: host's socket seat 1: guest's socket passes each frame to the other seat WebSocket WebSocket env.ROOM.idFromName("Xq3…f9A") same name → same object

Cloudflare decides where the Room runs, normally near whoever reached it first. The other player's traffic crosses Cloudflare's own network to get there. Nobody has to say in advance where a game lives.

There is no "create" call. env.ROOM.idFromName(name) turns any string into an ID, and env.ROOM.get(id) returns a stub, a local handle for talking to that object. The first request to reach a name brings the object into existence; if it has stored anything, it finds that data again the next time it wakes. Farboard adds its own rule on top: only the host's first join, marked create: true, may save a new room. A guest who arrives at a room with no stored record is told the game does not exist.

The invite

One secret gives the room's name and the encryption key

The two players have to agree on a room name without any server introducing them, and the relay should not be able to read their moves or their chat. Both come from a single random value in the invite link.

Deriving the room id and key from the invite secret A random 128-bit secret travels in the invite link's fragment. Each browser runs HKDF twice on it: once to get the room id, which is sent to the relay, and once to get an AES-GCM key, which never leaves the device. IN EACH PLAYER'S BROWSER · public/js/netcrypto.js Invite secret 16 random bytes, in #join=<secret> never sent to a server roomId HKDF(secret, "room") key (AES-256-GCM) HKDF(secret, "key") SENT TO THE RELAY /v1/rooms/<roomId> A name for the room. The key cannot be worked out from it. STAYS ON THE DEVICE Seals every move before it is sent, and opens every move that arrives.
Life of a game

What one game actually triggers

Cost is what shaped the choices above: one object per game keeps the relay to a single service, and hibernation means a game costs almost nothing while both players think. This section and the next three put numbers on that.

The counts assume a typical game: 40 moves each (80 plies) over about 25 minutes, both tabs on screen, and one reconnect per player (a phone going to sleep, say). They are worked out from the relay code, not measured from billing, so treat them as estimates.

  1. Both players open the page HTML, CSS and JS come from static assets. The home card asks /v1/info and /v1/stats.
    4 Worker req · 2 DO req · 2 rows read
  2. Host creates the invite, guest joins Each opens a WebSocket. The Room reads its record, saves the new seat and sets its alarm. The host's join also asks Budget to count one new game.
    2 Worker req · 3 DO req · ~5 rows read · 4 rows written
  3. 80 moves, plus a few syncs and requests Each sealed frame is counted and passed to the other seat. Incoming WebSocket messages are billed at 20 to 1.
    ~90 messages · ~180 rows read · ~2 rows written
  4. Keep-alive pings Each visible tab pings every 50 seconds, so a 25-minute game adds about 30 per player. Hidden tabs do not ping.
    ~60 messages · ~60 rows read
  5. One reconnect each A new WebSocket, a fresh join and a sync to catch up on anything missed.
    2 Worker req · 2 DO req · ~4 rows read
  6. Both players close the game Each leave updates the room's record. When both have gone, the alarm is set for a week later.
    ~2 rows read · 3 rows written
  7. A week later, the alarm fires The room deletes everything it stored. The game no longer exists anywhere except on the two devices.
    1 DO req · ~3 rows written

Rounded totals per game: 8 Worker requests, about 16 Durable Object requests (the ~156 incoming WebSocket messages count as about 8), about 250 rows read and about 15 rows written. The row-read count is an upper bound: it assumes the room hibernates between every message and so reloads its message counter each time.

The units on the bill

Requests, GB-seconds, rows and storage

Cloudflare's free plan measures Farboard's relay in five units. Each resets at 00:00 UTC, except storage, which is a running total. On the free plan, reaching a limit makes further operations of that kind fail until the reset. Nothing is ever charged.

Worker request

100,000 / day

One run of the Worker's code for an incoming HTTP request. A WebSocket counts once, when it opens, however long it then stays open. Requests for the page's own files are served as static assets and are not counted, but a request for a file that does not exist runs the Worker and is counted, even though it only gets a 404.

Opening Farboard's home screen costs 2: /v1/info and /v1/stats. The HTML, CSS, JS and icons are free.

Durable Object request

100,000 / day

Each HTTP request, RPC call or alarm that reaches a Durable Object. Incoming WebSocket messages count too, but at a 20:1 ratio: 20 messages cost 1 request.

A game's ~156 incoming messages (moves, pings, syncs) count as about 8 requests. Chat adds one incoming message per chat message plus one delivery confirmation. Even a game that uses the whole chat allowance (150 messages each, so 300 messages and 300 confirmations) adds about 30 requests.

GB-second (duration)

13,000 GB-s / day

A gigabyte-second: memory multiplied by time, not network bandwidth (it is not a gigabit). Every Durable Object is billed as if it used 128 MB, or 0.128 GB, for each second it is awake. One object awake for one second costs 0.128 GB-s, so 13,000 GB-s is about 28 hours of one object awake per day.

A 25-minute game whose room stayed awake throughout would cost 1,500 s × 0.128 = 192 GB-s, so only about 67 such games would fit in a day. Farboard's rooms use the WebSocket Hibernation API, which means only the milliseconds spent handling each message count, well under 1 GB-s a game.

Hibernation

not metered

Between messages, Cloudflare can take a room out of memory while keeping both players' WebSockets open on its side. The next message wakes the room. A room that is able to hibernate is not billed for duration, even before it actually hibernates.

A game where both players have wandered off costs nothing at all while it waits.

SQL rows read / written

5M read · 100k written / day

Each Durable Object has a small SQLite database. Farboard uses the simple key-value calls on top of it: each get() reads a row, and each put(), delete() or setAlarm() writes one.

Joining a game reads the room's record (1 read) and saves the new seat (1 write). A move usually reads the message counter and the alarm time (2 reads) and writes nothing.

Stored data

5 GB total

The size of all Durable Object databases added together, at any moment. This is a running total rather than a daily allowance, and data you delete stops counting.

A room keeps a few hundred bytes of data. It is deleted a week after its last message, so 100 games a day holds at most about 700 rooms. Even with generous database overhead that is under 20 MB.

Daily budget

How much of the free plan a day of games uses

Move the slider to change the number of games in a day. Each bar shows that day's use of one allowance, using the per-game estimates above. Visitors who look at the home screen without playing add 2 Worker requests, 1 Durable Object request and 1 row read each.

100

The relay's own cap (DAILY_GAME_LIMIT, default 100) stops new games long before any allowance runs out. At 100 games a day the busiest allowance, Durable Object requests, sits near 2%.

Other platforms

The same app on AWS or Vercel

The page is static and would host for free almost anywhere. The part that changes is the relay: something has to hold two WebSockets, pass frames between them, and remember seats and game counts. On Cloudflare that is one object per game. Elsewhere, the same jobs are split across several services.

Cloudflare (today) AWS Vercel
Page Worker static assets S3 + CloudFront Static deployment
WebSockets Room Durable Object (hibernating) API Gateway WebSocket API Vercel Functions WebSockets (public beta since June 2026)
Passing a move to the other player Same object, one send() A Lambda per message looks up the peer's connection in DynamoDB, then calls PostToConnection The two sockets may sit on different instances, so frames go through Redis pub/sub (Upstash)
Seats, counters, clean-up Object's SQLite + alarm DynamoDB conditional writes, TTL for expiry Redis keys with expiry
Connection limits None that affect a game 10-minute idle timeout, 2-hour maximum 5 minutes per connection on Hobby (300 s max duration), then reconnect
Free allowance that matters 100k DO requests/day; hibernation makes idle games free Lambda 1M requests/month and DynamoDB 25 GB always free. API Gateway's 1M messages/month is a 12-month offer; accounts opened since July 2025 get credits instead Hobby: 1M invocations, 4 h Active CPU, 360 GB-hours memory per month. Upstash free: 500k commands/month
Rough cost at 100 games/day $0 ≈ $1 a month ~950k API Gateway messages at $1/M; connection-minutes, Lambda and DynamoDB add cents or stay free $0–2 a month Upstash's free tier covers ~40 games a day; above that about $1.40/month. Memory held open by sockets may outgrow Hobby's 360 GB-hours
When usage spikes Free plan fails closed at the limit; online play pauses until 00:00 UTC; never billed Keeps serving and bills per use; AWS Budgets can alert but do not stop spending Hobby stops serving past its included usage until the next cycle; Pro ($20/month) bills overage
Terms Any use Any use Hobby is for personal, non-commercial use only
Porting effort — Large: rewrite room.js as three Lambda handlers with DynamoDB, plus infrastructure as code and IAM Medium: a Node WebSocket server plus Redis for seats and relaying; handle a reconnect every 5 minutes

How the AWS figure is reached

API Gateway meters messages in both directions: about 156 in and 160 out per game, so roughly 316 × 3,000 games a month ≈ 950k messages, or about $0.95. Two players for 25 minutes is 50 connection-minutes a game, about $0.04 a month at $0.25 per million. The ~470k Lambda invocations and their DynamoDB lookups fit in the always-free tiers.

How the Vercel figure is reached

Relaying a frame through Redis takes about 2–3 commands (publish, counters), so about 400 commands a game. 500k free commands a month is about 1,250 games, or 40 a day. The remaining ~700k commands at $0.20 per 100k come to about $1.40. Function memory is billed while an instance holds open sockets, which is hard to predict with Fluid compute sharing instances.

The short version: AWS and Vercel would each run Farboard for about a dollar or two a month. Cloudflare's advantages are that the design is simpler (one object per game instead of a function, a database and a pub/sub service), idle games cost nothing, and the free plan stops at its limits instead of sending a bill.

Design alternatives compares this design with a relay on your own server (VPS) and with WebRTC peer-to-peer.

Checking the real numbers

Where to see actual usage

If the real figures differ from the estimates here, the per-game constants live at the top of this page's script and are easy to adjust.

Sources and notes

Prices and allowances checked on 26 September 2026 from Workers pricing, Durable Objects pricing, API Gateway pricing, Vercel pricing, Vercel WebSockets, Vercel Functions limits and Upstash Redis pricing.

Farboard's own numbers are estimates from relay/src/room.js, relay/src/budget.js, relay/src/index.js and public/js/relayclient.js.