Every DOOH platform solves the same basic problem: a screen somewhere needs to know what to play, right now, even when that location's internet drops three times a day. From there, the decisions diverge sharply.
Ours was to run everything on Cloudflare's edge. No application server, no third-party managed queue, no Docker anywhere — not even in development.
The shape of it
Four pieces do the heavy lifting:
- Workers serve the whole API (Hono). No player request travels to a fixed datacenter: it is answered at the point of presence nearest the screen.
- D1 holds the global catalogue — tenants, users, the player directory.
- R2 holds the media. No egress cost, which matters when every screen pulls the same video files.
- Durable Objects hold each customer's state. One
TenantDOper organization, with SQLite embedded.
The fourth is the one that shapes everything else.
One Durable Object per customer
The normal instinct is a playlists table with a tenant_id column and a WHERE clause on every query. It works, and it is where almost every SaaS starts.
We went the other way: each organization gets its own Durable Object, with its own database. That customer's playlists, schedules, proof-of-play records and assets live inside it, and nowhere else.
That buys three things at once:
- Isolation by construction. No query can leak data across customers, because there is no shared table to leak from. The classic bug of forgetting
WHERE tenant_idstops being possible. - Coordination without a queue. A DO is single-threaded by definition. When ten screens belonging to the same customer reconnect at once after a power cut, they talk to the same object, in order. We need no distributed lock for any of it.
- Locality. The object migrates toward whoever uses it most.
The cost is real and worth stating: you lose the global query. "How many screens are active across the whole platform?" stops being a SELECT COUNT(*) and becomes a fan-out, or a projection you maintain deliberately. Every question that crosses customers now takes explicit work.
For our case, it was worth it. The questions that matter day to day — did this playlist change, is this screen alive, what played on it yesterday — all sit inside a single customer.
What hurt
Migrations inside the DO. Migrating one central database is an operation; migrating N databases, each waking up whenever its customer appears, is a different one. Our rule today is strict: SQL running at TenantDO boot is DDL only, and it must be cheap. Any heavy backfill goes to an alarm or waitUntil, off the critical path. We learned that the usual way — a boot that got too slow.
Per-request CPU limits. Workers are not the place to transcode video. Nothing CPU-heavy runs inline; it either gets its own service or becomes a Workflow.
Observability. State spread across thousands of objects does not show up on a dashboard by itself. Projecting what matters outward takes deliberate effort.
What we never had to build
The easiest part to underestimate is what disappears from the backlog.
We have no server to scale, no cluster to patch, no TLS certificate to renew, no CDN to configure in front of the media. wrangler dev brings the whole stack up on a developer's machine — no docker compose, no local Postgres, no six containers to remember to tear down.
For a small team running a screen network that has to stay up, that is the decision that pays the most.

