Delay, Not Loss: FOQS, Meta's Trillion-Item Priority Queue
Facebook Ordered Queueing Service (FOQS) is the queue underneath Meta's asynchronous world, processing close to a trillion items a day for hundreds of use cases, from notifications to video encoding. Its defining promise is what happens when consumers fail: enqueues keep landing, backlogs grow into the hundreds of billions of items, and nothing is lost - downstream failure becomes delay, not loss. Keeping that promise is the hard part, because FOQS is built on sharded MySQL, and MySQL slows down at exactly the wrong moment. The more pending work piles up, the more old row versions the database must drag through on every scan. The article walks the engineering that keeps a hundred-billion-item backlog from slowing the queue that holds it. Four moves do the work. Checkpoints keep each scan from re-reading old history. Per-shard indexes are merged into one priority order. Short delivery leases (a countdown a consumer must confirm within) turn a crash into a redelivery, not a loss. And circuit breakers stop feeding unhealthy shards.
Kill the consumers and watch a hundred-billion-item backlog become delay, not loss. Then drain it by priority, crash a consumer while it holds an item and watch the lease decide the item's fate, trip a slow shard's circuit breaker, and toggle checkpointing to see it hold the queue's scan speed steady or let it slow down as the backlog grows.
Problem
The Facebook ecosystem runs on thousands of distributed systems and microservices, and a large share of their work is better done asynchronously. Some of it is deferred to off-peak hours for better resource use, some scheduled for a future date, some just passed dependably between services. What all of those cases share is the need for a queue: a place to store work that must happen later, or elsewhere.
FOQS - Facebook Ordered Queueing Service - is that place for hundreds of use cases. Async, Facebook's general-purpose asynchronous compute platform, leans on FOQS to hold large backlogs and defer delay-tolerant work to off-peak hours. Video uploads are broken into components stored in FOQS for later encoding. Language translation splits computationally expensive jobs into parallel work items.
The scale sets the terms of the problem. FOQS processes close to a trillion items a day, and its backlogs have reached hundreds of billions of items. The post cites that number with pride, because a queue's backlog capacity is precisely its ability to absorb widespread downstream failure. When consumers break, the queue is the component that must not: producers keep enqueueing, items persist, and the failure surfaces as delay rather than loss.
But a priority queue makes a promise that gets harder as it scales on sharded storage. Items carry a user-specified 32-bit priority and a deliver_after timestamp, and dequeue must return the most important, oldest-ready work first. Yet the items live as rows spread across many MySQL shards, and each shard is owned by a single host and can see only its own rows. And MySQL itself pushes back against the queue's mechanics. The background operations that run FOQS - making deferred items deliverable, expiring leases, purging TTL'd items - are all timestamp scans, and MySQL retains old row versions in a history list whose length degrades read performance. The deeper the backlog, the longer the history, the slower the very queries that drain it. The queue's core engineering problem is keeping its performance independent of how full it is.
Solution
An item in FOQS is one row in a MySQL table, carrying a namespace, a topic, a priority (lower is more urgent), an immutable payload up to 10Kb, mutable metadata, a deliver_after timestamp, a lease duration, and a TTL. Its FOQS-assigned ID encodes the shard ID plus a 64-bit primary key, so every item's location is readable from its name. A topic is one logical priority queue, named by a string. Topics are cheap: name a new topic when you enqueue and it exists; drain its last item and it is gone. A namespace is the unit that separates tenants sharing the system. Each namespace is promised a guaranteed number of enqueues per minute and mapped to a tier - a pool of FOQS hosts and MySQL shards serving a group of namespaces. Shard Manager assigns each shard to exactly one host.
The enqueue path is buffered. A request lands on a host and immediately returns a promise, and a background worker for that shard writes the buffered items into MySQL rows, completing the promise once the insert lands. In front of each shard sits a circuit breaker, which the post cites by name. It marks a shard unhealthy on slow queries or elevated error rates over a rolling window and stops the worker from feeding it until it recovers, so FOQS does not keep piling new items onto an already-unhealthy shard. If an enqueue lands on an overloaded host, enqueue forwarding routes it to a host with capacity.
Dequeue is where the priority promise is kept. The API takes (topic, count) pairs and returns items ordered by priority, ties broken by older deliver_after. A host's items are spread across all the shards it owns, so to find the highest-priority work it must combine them. Each shard keeps a small in-memory index of its ready items, sorted by priority. The Prefetch Buffer merges those per-shard lists into one globally ordered stream in the background - a k-way merge, the standard way to combine several already-sorted lists. It then reads the winning rows, marks them 'delivered' so the same item can't go to two consumers, and stages them for the dequeue API. The buffer refills in proportion to how fast each topic is drained, so busier topics get more items staged ahead.
Every delivery is handed out on a lease, which is just a countdown. When an item is dequeued, its clock starts. If the consumer confirms success (an ack) the row is deleted; if it reports failure (a nack) the item is rescheduled for later. But if the consumer does neither before the clock runs out - because it crashed, say - FOQS follows the topic's policy. At-least-once redelivers the item (no loss, but the consumer might see it twice); at-most-once deletes it (no duplicate, but that item is gone). A nack can carry a client-chosen delay, letting a failing consumer wait longer between attempts (exponential backoff), and can update the item's metadata to stash partial work. A lost ack is harmless too: if it never reaches FOQS, the lease expires and redelivery takes over.
FOQS is pull-based: consumers ask for work rather than having it pushed at them. The post defends that with a survey of its workloads - delays spanning milliseconds to days, consumption from tens of items a minute to more than ten million, per-topic and per-item priorities, and region affinity for some items. Push would make the queue responsible for not overwhelming every consumer; pull keeps the queue simple and lets each consumer set its own pace. The cost is that consumers must discover which topics have work, which FOQS answers with the getActiveTopics API and a routing layer.
Two mechanisms keep the queue fast and durable at depth. The first is checkpointing. FOQS's background jobs find work by scanning for rows whose timestamp is at or before now, but that scan forces MySQL to walk a list of old row versions as long as everything pending. A checkpoint is a saved marker of the last timestamp already processed; adding it as a lower bound means each scan covers only the new work since last time, so read performance stays flat no matter how deep the backlog. The second is disaster readiness: every MySQL shard is replicated asynchronously to two other regions, with the binlog (MySQL's change log) copied synchronously to a building nearby. Draining a datacenter then costs a few milliseconds of read-only while a replica catches up, is promoted, and the shard moves to a host in the new region. Namespace rate limits are enforced globally, not per-region - the post is direct that regional guarantees are impossible.
Tradeoffs
- Pull buys the queue simplicity by exporting two hard problems to everyone else. The post's own table says it. Push delivers data as soon as it's ready, but requires the queue to solve overload for every consumer type. Pull keeps the queue layer simple, and makes consumers discover where data lives and pace themselves against their own latency needs. With workloads spanning milliseconds-to-days delay tolerance and 10-to-10-million items a minute, FOQS chose to keep the queue itself simple and push the hard work onto its consumers. It then had to build getActiveTopics and a routing layer to soften the very cost - consumers now having to find where their work is - that this choice created.
- At-least-once delivery makes idempotency every consumer's tax. When a consumer crashes, its lease expires and the item is redelivered - that is the loss-prevention doing its job. But the same thing happens to a consumer that was only slow, not dead: its lease expires and the item is delivered a second time. So every at-least-once consumer has to be built to tolerate seeing the same item twice. The queue's durability guarantee is real, and it is exactly one half of a contract whose other half - deduplication, idempotent processing - is signed by every one of the hundreds of use cases downstream.
- The priority promise across shards is paid in standing machinery. Because each shard knows only its own rows, 'most important item next' costs per-shard in-memory indexes, a background k-way merge, delivered-state writes to prevent double delivery, and a prefetch buffer whose replenishment must track per-topic demand. All of it runs continuously, whether or not anyone is dequeuing. And the promise is scoped, not global: the reduce runs across the shards a host owns, and cross-region, FOQS routes dequeues toward hosts with the highest-priority items rather than pretending one worldwide ordering exists.
- Building on MySQL trades a perfect fit for the job against years of operational maturity, and the history list is what that trade costs. Rows-as-items means FOQS inherits MySQL's replication, tooling, and operability, and also its MVCC mechanics: timestamp scans lock more than they return, old versions accumulate, and reads slow in proportion to pending work. That is the buffer degrading under the backlog it exists to hold. Checkpointing is an elegant bound, and it is also a workaround built inside someone else's storage engine; the queue's flat performance curve is maintained by discipline, not given by design.
- Multitenancy guarantees are global because regional ones are impossible. Namespaces get guaranteed enqueues-per-minute enforced across all regions - the honest scope, since failover-driven promotions cause large capacity imbalances and FOQS cannot assume how much capacity is available where. Colocating capacity with traffic is best-effort mitigation, not contract. A tenant's guarantee is real in aggregate and soft in any particular place, a subtlety every capacity-planning conversation downstream inherits.
- Disaster readiness is bought in layers with visible seams. Asynchronous cross-region replication keeps steady-state cheap; the synchronous binlog copy to another building bounds the loss window; drains cost a few milliseconds of read-only during promotion. But promotions strand capacity in the wrong regions, forcing the routing improvements the post describes. And the Looking Ahead section concedes the frontier plainly: handling failures of multiple domains - region, datacenter, rack - and guaranteeing no data loss when a region goes down remain the team's near-term challenges. The follow-up disaster-readiness post exists because this story wasn't finished here.
Patterns in this article
- Queue with Guaranteed Delivery
FOQS puts this whole property in one platform: items persist, leases turn consumer crashes into redeliveries, ack/nack give at-least-once semantics, and backlogs of hundreds of billions of items are cited as the system absorbing widespread downstream failure. Discord reached for the same guaranteed-delivery property in its PubSub service after Redis dropped messages under pressure; here Meta builds the property itself, as a shared multitenant platform.
- Circuit Breaker
The post names the circuit breaker directly. Each MySQL shard's enqueue worker watches slow-query and error rates over rolling windows, marks the shard down, and stops feeding it new items until it recovers, refusing to deepen an overload already in progress. It is the same reflex as Shopify's payment-gateway breakers, but pointed one layer down the stack - at your own storage, not an outside partner.
- Checkpoint-Bounded Scans
This is the post's most transferable idea. Background operations that scan by timestamp carry a persistent lower bound, the last checkpoint processed, so the where clause is bounded on both sides. MySQL's history-list walk then stays short no matter how deep the backlog. It is what makes the queue's performance independent of how full it is.
Also solving this
Other systems in behindscale's Buffer degrades under backlog class: