Skip to content
Go back

Building a Scalable Real‑Time Chat with Cloudflare Durable Objects

One of the projects I was involved in was a sports streaming platform (similar to Twitch). The owner operated in a large market and expected a big audience. The platform was new, and we had a short runway before the first major live stream.

One of the key features was real-time chat while viewers watched auto races. Based on early estimates, we expected 200k+ concurrent users for the first event. Only registered users could write, but everyone could read, and all viewers needed near real-time delivery. We planned for a WebSocket-based chat for all users.

The rest of the platform used .NET for APIs and background jobs, Postgres as the primary database, Redis for caching, and a Next.js client. For encoding and CDN, we used multiple providers with geo-distributed fallbacks.

Our initial idea was SignalR backed by our .NET API fleet. But with unpredictable concurrent users and limited time before launch, it was hard to size the fleet. That felt risky. A serverless approach was a better fit because usage was spiky and event-driven.

That is where Cloudflare Durable Objects came in. We already used Cloudflare: our API gateway ran on Workers, the Next.js client was deployed on Workers, and we relied on Cloudflare for DDoS protection and WAF rules. Adding Durable Objects was a natural choice.

Durable Objects let you maintain long-lived WebSocket connections with per-object state. The basic flow looked like this:

Real time chat diagram

Real time chat diagram

That is all it takes, except for one serious limit: Durable Objects have a soft cap of about 1,000 requests per second. It is high, but with hundreds of thousands of concurrent users, we could hit it quickly. We needed to scale beyond a single object per video.

The answer was sharding. Instead of placing everyone in one Durable Object, we would spread users across many. But we needed a deterministic way to assign users so we did not create too many objects. Given the soft limit, we targeted about 500 users per object. Random assignment would either require a predefined shard count or create too many empty rooms on low-traffic videos.

We needed a shared state to track which rooms existed and how full they were. Our first attempt used Cloudflare D1 (serverless SQLite):

Real time chat diagram

The approach worked, but load tests showed D1 write limits on the connection path. The bottleneck was the initial assignment; afterward, everything stayed within the ChatRoom Durable Object.

If D1 could not handle the load, we could use Durable Objects as the orchestrator. Each Durable Object has its own SQLite-backed state, but a single orchestrator could also become a bottleneck. So we sharded that layer too, with a fixed number of orchestrators. The final design:

Real time chat diagram

With five orchestrators, we scaled burst capacity to roughly 5,000 requests per second (soft limit; in practice it can go higher). We also implemented client-side retries so failed connection attempts would recover.

There were a few extra challenges to handle.

Race conditions: between room assignment and the actual socket open, other requests can race and exceed the 500 limit. We handled this in two ways:

The next challenge was keeping the orchestrator’s counts accurate. After assignment, the connection goes straight to the room and the orchestrator does not know if it succeeded. We solved this with the Durable Objects alarms API (a scheduled callback, similar to setTimeout):

This state keeps the room count minimal by always using the first available room under the limit. For example: if room_1 and room_2 are at 500, room_3 has 400, and room_4 has 50, we fill room_3 until it reaches the limit. If a connection drops from room_1, the next connection goes there. Balancing by lowest occupancy would create too many rooms with low utilization; the “first available” strategy keeps the number of Durable Objects bounded. A 500-connection limit is still manageable in a single room.

We also needed to broadcast the total number of viewers for each video. Each orchestrator already knew the counts for its own rooms, but we needed cross-orchestrator aggregation. We implemented a periodic sync:

Broadcasting chat messages is similar: the gateway notifies all five orchestrators, which broadcast to their rooms, which in turn push the message to all WebSocket connections.

Some smaller but important details:

To load test, we used k6 with a custom script that opened WebSockets, sent pings, and held connections for a defined duration. We ran the k6 Kubernetes operator from four clusters in different regions and reached 300k concurrent WebSocket connections. While that was running, another script posted chat messages via the API, and the k6 clients verified viewer counts and message delivery. We stopped at 300k because we hit port exhaustion issues in the Kubernetes clusters (and, yes, we briefly impacted some production services while testing).

One more story: during development, we ran into a Durable Objects bug involving alarm scheduling under certain state transitions. Objects would enter a corrupted state and become unusable. With many objects created during load tests, we hit this occasionally. Logs did not help much, so our workaround was to change name patterns and create fresh objects. A few weeks later, someone from the Durable Objects team reached out after noticing rare errors from our account. We shared our usage patterns and they fixed the issue.

This was the first version of the chat system. After a few months in production, we saw that the orchestrator model was not ideal: too many unnecessary Durable Objects were created, and the extra reads and writes drove costs up. Once we released a new version of our .NET API, we moved orchestration there and now use Redis to manage active rooms and connection counts. The WebSocket connections remain in chat room Durable Objects.


Share this post on:

Previous Post
Building the Colanode Sync Engine