Why Durable Objects for Queue Management

Waiting Room is a global system that must enforce customer-defined limits on application traffic. That means controlling admission from anywhere on the network, not just at a single origin. The design goal was to build a system that could track active users across regions and decide admission in real time without adding noticeable latency to legitimate traffic.

Cloudflare Workers provide the core checkpoint logic at every edge location on Cloudflare's network. But for the system to work, there must be a way to maintain shared bookkeeping across all locations: exactly how many users are active on a given application right now, and how many more have been allowed in during the current interval. That shared state is why Waiting Room is built on Durable Objects.

Each configured waiting room has a single Durable Object assigned to it. Every request to the waiting room routes this critical state through that coordinator object. All active sessions report activity to it, and all pre-admission decisions (i.e., does this user get in, or do they wait?) are made there. Whether a request arrives from Sydney or Chicago, it lands on the same Durable Object, which has an authoritative view of session count and recent admissions.

The trade-off is local: a single object can only handle one request at a time by default, although WebSockets and I/O waiting do not block it. A waiting room Durable Object processes admission decisions quickly enough that serial handling is not a bottleneck in practice. To improve throughput, requests that don't need a change in coordinated state can be evaluated directly at the edge. But any requirement to change state—such as admitting a new user or renewing a session—hits the Durable Object.

This split avoids needless trips to the coordinator while ensuring that nothing affects the admission count without going through the authoritative source. The architecture works without leaders, locks, or custom protocols: Durable Object storage merges edge and object state reliably, and its security model is used to represent and protect all software components.

The admission flow

A user requesting access to a protected application gets routed to Cloudflare. Workers handles the request and checks whether a waiting room cookie is attached:

  1. Cookie of active user: If an active session cookie is present, the request is admitted directly to the origin. The Worker updates the user's actual activity timestamp asynchronously using the Durable Object's fetch handler—explicitly bypassing the object's serialization to avoid latency on every asset request.
  2. Cookie of user who was waiting: If the request holds a cookie from a user previously placed in the queue, the Worker asks the Durable Object whether this user has already been admitted or should still wait. That check happens at reduced frequency (every 20 seconds) to avoid polling too frequently.
  3. No cookie: A new request presents a challenge: is this user allowed to enter, and if yes, should they?

For the last case, the Worker attempts to enter the user into the application by calling the Durable Object's admit method. The coordinator evaluates the request against the active count: it increments a count, stores the fractional duration of time spent in the system, and—using the stored state of Session Duration, Total Active Users, and New Users Per Minute—decides whether the user was admitted immediately or must wait. When the user is admitted, a cookie is set at the edge to mark that session active.

That store of durations is engineered specifically to be correct against the "sliding window" problem of session expiry. Instead of listing timestamps per session, the object keeps track of total time spent in sessions and the time the last session ended. Because an active user is always re-admitted at the front of the window, this form of counting guarantees consistency for estimation purposes without requiring iterative garbage collection of stale entries.

Once the initial decision is made (either entry or queue placement), the Worker hands off: a waiting user is issued a "waiting" cookie, plus HTML that refreshes with remaining wait time. The actual status of a user is conveyed in the query string of the refresh requests (for example, ?cloudflareWaitingRoom=1). This flag tells the Worker to interact only with the Durable Object, not spend cycles on serving the asset.

Cloudflare Waiting Room Embedded Image - oarcIM

A waiting room holds users back until capacity frees, at which point requests arriving at the edge carry a cookie that says "was waiting." At this point, the Worker checks the coordinator—if there is room, the user is immediately sent to the origin. This allows the queue to move without requiring the user to take any extra action. New requests that "spoof" the waiting-room cookie are caught at the coordinator, since fresh admission is still subject to the same count check.

BLOG-518 Embedded Image - 0XIMPC

Capacity frees over time as sessions expire, but the objective is to keep the origin protected and the user experience seamless: the user waits for the advertised time, receives a smooth hand-off, and is not asked to repeat steps. To further this, there is no per-request network blockage; all requests arriving with the "was waiting" cookie are marked busy at the edge without consulting the object—the purpose of this flag is to prevent overloading the origin before admission is granted. The origin admission occurs precisely once.

For the larger technical picture, the pattern is not unique to our product: routing configuration to a single object for global coordination scales reasonably for control-plane purposes, even if each request is serialized. Using Durable Objects for state and Workers for evaluation provided the build-time flexibility we needed, and it kept the perimeter of the system both very explicit and very testable.

Inside the queue: tickets, slots and fairness

When a site behind Cloudflare enables Waiting Room, each visitor receives an encrypted cookie upon their first request. That cookie acts as a ticket. The waiting room itself enforces a cap on how many users may reach the origin at once, based on the customer's configured limit minus the number of users currently admitted. Those available spots are called user slots; once sessions end, slots free up and new users can enter. During a traffic spike, slots shrink until none remain and new arrivals are queued.

Coordinating slot availability across every Cloudflare data center in real time would be slow and fragile, so the system divides the global pool of slots among data centers using each one's recent share of traffic. Because that share is derived from historical data — which cannot change — it remains reliable even when live traffic information is delayed.

For example, at Thu, 27 May 2021 16:33:20 GMT, a data center looks back at the traffic distribution from Thu, 27 May 2021 16:31:00 GMT. If Nairobi and Dublin each drew 50 users in that minute, they split the available slots equally. A minute later, at 16:34:00 GMT, the split reflects the 16:32:00 GMT ratio of 45/55, so Nairobi gets 45% of slots and Dublin gets 55%. This division keeps the overshoot small even when propagation lags, because only an individual data center's allocation can be exceeded temporarily.

Tracking position with buckets and cookies

Rather than tracking every individual in the queue, users who arrive within the same minute share a bucketId. The bucketId is derived from the time of first arrival: everyone who visited between 19:51:00 and 19:51:59 gets bucketId 19:51:00. This clustering makes it practical to manage queues at scale.

The cookie handed to each user contains three key fields. bucketId records which cluster the user belongs to, which determines their place in line. acceptedAt is the timestamp when the user was first admitted to the application. lastCheckInTime tracks when the user was last seen in either the waiting room or the application. For accepted users, the cookie's expiry is set to the customer-defined Session Duration minutes after the last check-in, so the session automatically ends if the user stops polling.

Building a global view: state aggregation

When a waiting room is first created, there is no recorded traffic, so the only governing signal is the customer's configured limit. As users arrive, each data center runs a local service that periodically reports its issued tickets to a coordinating service. Those reports are aggregated into a waiting room state: a sorted list of all bucketIds currently queued, along with a count of active users on the application.

Suppose Nairobi and Dublin each queue users behind different bucketIds. Their local states are merged into a global structure like this:

activeUsers: 201, // 151(Nairobi) + 50(Dublin)
buckets: 
[  
  {
    key: "Thu, 27 May 2021 15:54:00 GMT",
    data: 
    {
      waiting: 2, // 2 users from (Nairobi)
    },
  }
  {
    key: "Thu, 27 May 2021 15:55:00 GMT", 
    data: 
    {
      waiting: 50, // 20 from Nairobi and 30 from Dublin
    }
  },
  {
    key: "Thu, 27 May 2021 15:56:00 GMT",
    data: 
    {
      waiting: 60, // 20 from Nairobi and 40 from Dublin
    }
  }
]

In this aggregated state, the waiting field shows how many users sit in each bucket, and activeUsers reflects how many are currently on the origin. If the customer has set Total Active Users to 200 and New Users Per Minute to 200, but activeUsers is 201, then user slots available equal 200 − 201 = -1, and nobody can enter. Once some sessions end and activeUsers drops to 148, there are 52 free slots. Those slots go to the oldest buckets first: if two users are waiting from bucket Thu, 27 May 2021 15:54:00 GMT and 50 from bucket Thu, 27 May 2021 15:55:00 GMT, both groups get admitted, and users can be released from any data center that holds them — preserving fairness regardless of where they first connected.

When a bucket has more users than remaining slots, the allocation becomes trickier. The system then divides the eligible slots among data centers using the same historical traffic ratios as before. For instance, at Thu, 27 May 2021 16:34:10 GMT, Nairobi and Dublin each look at their 16:32:00 GMT traffic share and admit a proportional subset of their waiting users.

Estimated wait time

To display an expected wait, the service looks at the user's bucketId and counts everyone ahead of it in the global sorted list. That count is divided by the average number of users admitted per minute, which is tracked alongside active users. The result is the estimated minutes shown on the waiting room page.

avgUsersToWebApplication:  30,
activeUsers: 148,
buckets: 
[  
  {
    key: "Thu, 27 May 2021 15:54:00 GMT",
    data: 
    {
      waiting: 2,
      eligibleSlots: 2,
    },
  }
  {
    key: "Thu, 27 May 2021 15:55:00 GMT",
    data: 
    {
      waiting: 50,
      eligibleSlots: 50,
    }
  },
  {
    key: "Thu, 27 May 2021 15:56:00 GMT",
    data: 
    {
      waiting: 60,
      eligibleSlots: 0,
    }
  }
]

In this example, a user with bucketId Thu, 27 May 2021 15:56:00 GMT has 60 users ahead. With 30 active users reaching the application per minute, the estimate is 60 / 30, or 2 minutes.

Choosing the Coordination Layer

The decision to build on Workers was straightforward. The platform scales horizontally by running isolates across the network, and the tooling around it keeps iteration fast. But the harder question was how to coordinate state. Workers need to share waiting-room state globally, and they need updates quickly enough to react to sudden traffic spikes. The allocation scheme depends on the proportion of traffic from two minutes prior, which means aggregating data worldwide and making it available inside that window. At the same time, the design needs fast coordination within a data center to adjust on short notice.

Two storage options were evaluated before Durable Objects entered the picture: the Cache API and Workers KV. Cache is shared only at the data-center level, so global coordination would require KV. In that model, each worker would write a key describing the requests it received and how it processed them, with keys expiring after a few minutes if a worker stopped writing. A worker would periodically list keys in the KV namespace to reconstruct the global state.

That setup had fundamental problems. Waiting-room state changes constantly to mirror traffic, making the workload write-intensive, while KV is built for read-heavy patterns. The proof of concept ran more expensive than expected. Worse, KV is eventually consistent, and the propagation delay across data centers is unacceptable when a traffic spike can emerge in several locations at once.

The Centralized Alternative

The other option was running databases in the core data centers. The Cache API can hold data locally at the edge, and frequent communication with the core would keep that cache reasonably fresh. Fine-grained control over propagation timing would be possible, and the latency on the hot path could stay low.

But the workload is write-heavy and the data is short-lived, which rules out a standard relational database. The existing database clusters maintained by in-house specialists could not be reused. Instead, the team would need to set up and maintain an in-memory data store such as Redis: install clusters in each core location, tune the configuration, replicate data between them, and build a proxy service to gate access and validate writes.

This was workable but carried serious operational cost and introduced a hard dependency on the availability of core data centers. An outage there would affect the product globally. Edge-based resilience is better: if one data center goes offline, Anycast reroutes traffic to nearby ones and the web application continues unaffected.

Why Durable Objects Won

Durable Objects were in closed beta at the time, but they offered exactly what the design needed. One global Durable Object instance per waiting room replaces a single database. The instance can live anywhere in the world, with redundancy and availability handled by the platform, which means sharding comes for free. The model also isolates waiting rooms from each other: problems affecting one customer are less likely to spill into another.

The worker decides whether a user goes to the waiting room or the web application. Periodically, it sends counts to a Durable Object: how many users it queued and how many it let through. The Durable Object instance is created on first request and stays alive as long as it receives traffic, aggregating counters from every worker.

A Durable Object instance is only active while receiving requests and can be restarted during maintenance, which clears its in-memory state. To survive restarts, the state is backed up using the Cache API. That offers weaker guarantees than Durable Object persistent storage alone — data can be evicted, or the object can be moved to a different data center, forcing a cold start without cached data. But persistent storage at the edge has limited capacity, and the state can be rebuilt quickly from worker updates, so cache backup was deemed sufficient.

Sharding for Scale

Workers scale horizontally with traffic; Durable Objects do not. By design, only one instance of an object exists, running on a single thread. If every worker sent data directly to one global object, it would quickly become overloaded. The solution is two-stage sharding: per-data-center Durable Object instances report up to a single global instance.

A request reveals its data center through the cf.colo field on the request. The Data Center Durable Object tracks the number of workers in that location and aggregates their state. It also shares important information with workers — like the number of users making requests or the current worker count — and frequently updates the Global Durable Object, receiving information about other data centers in the response.

Workers and User Slots

If every worker in a data center called the Data Center Durable Object on every request, that object would be overwhelmed. Worker User Slots solve that problem.

Each worker tracks how many users it has sent to the web application and how many it has queued. The number of users a worker may let through is derived from the data center's total user slots divided by the number of workers in that data center. If two workers share a data center budget of 10 users, each gets five. This division is necessary because each worker decides independently whether to admit a user or queue them, without consulting other workers.

The worker count is dynamic, and new workers can appear between calls to the Data Center Durable Object, creating a risk of overshooting the limit. Pace the calls too aggressively, though, and the object cannot scale. For faster synchronization inside the data center, the Cache API provides a middle ground: on every interaction with the Data Center Durable Object, the worker saves a copy of the response to cache and regularly refreshes its in-memory state from that cache. The write rate from workers to the Data Center Durable Object is also adjusted adaptively based on the current worker count, preventing the object from being taken down during traffic shifts.

The Global Durable Object

The Global Durable Object is intentionally simple. It stores whatever it receives from any data center in memory and responds with everything it knows about all data centers. Periodically it saves its in-memory state to cache via the Workers Cache API, so it can recover from restarts the same way its data-center counterparts do.

How It Fits Together

Every request for a waiting room goes to a worker at a Cloudflare edge data center. The worker first checks the cache for the waiting-room state, rather than calling the Data Center Durable Object, to avoid overwhelming the instance during spikes. Reading from cache is also faster. Workers periodically request state from the Data Center Durable Object and write what they get into the cache, keeping it fresh.

Each worker knows its data center, and periodically calls the corresponding Data Center Durable Object. That call updates the worker's state at the data-center level and returns the waiting-room state. The Data Center Durable Object then sends the data center's state to the Global Durable Object and receives all other data-center states in reply. From that, it computes the overall waiting-room state and returns it to the worker.

The design deliberately allows the write rate from workers to the Data Center Durable Object, and from that object to the Global Durable Object, to vary with traffic. That is how the system keeps up with high request volumes without overpowering any single Durable Object instance.

Real-World Results and What’s Next

Waiting Room has already proven itself under production traffic. During the early months of the pandemic, it kept application servers online for a range of organisations, from private companies to entire national health systems. Several of those deployments ran at no cost through Project Fair Shot:

  • Verto Health used it to serve over 4 million vaccination-slot requests in Canada.
  • Ticket Tailor cut its peak resource utilisation from 70% down to 10%.
  • The County of San Luis Obispo stayed online through surges of up to 23,000 concurrent users.
  • The Ministry of Health in Latvia weathered spikes of thousands of requests per second without an outage.

Those cases demonstrate the core design principle: by shifting queue logic and state management onto Workers and Durable Objects, the origin server only ever sees a manageable, steady flow of traffic.

Waiting Room is now rolling out to customers on the Business plan. If you need to safeguard a web application against unpredictable spikes, you can sign up today. For developers who want to build similar stateful logic on the edge, Durable Objects remain available in Open Beta for experimentation.