When the Queue Pushes Back: DoorDash's Escape from RabbitMQ

In mid-2019, the Celery-and-RabbitMQ system powering more than 900 asynchronous tasks at DoorDash — order checkout, merchant order transmission, Dasher location processing — was frequently going down under excessive load, and when task processing went down, DoorDash effectively went down. The failure anatomy is the class's spine drawn precisely: sudden bursts left RabbitMQ in a degraded state with consumption far below normal, its Flow Control throttling fast publishers in a way they experienced as network latency, which cascaded as requests piled upstream; uWSGI's harakiri killed timed-out workers, whose reconnection churn fed the broker still more load; heavy use of Celery countdown tasks parked future work in the broker and drove outages of their own; and the primary-secondary HA mode reduced throughput while its failovers took 20-plus minutes, often stuck, and lost messages. Saba Khalilnaji and Ashwin Kachhara narrate the escape: a custom Kafka-based task system whose MVP — task names and pickled arguments on Kafka, a wrapper around Celery's @task annotation routing by dynamic feature flags — shipped in two weeks and cut RabbitMQ load 80% within a week of launch, migrated with dual-publishing, a temporarily doubled worker fleet, and lowest-risk tasks first. Then the honest sequel: the new buffer's own physics — Kafka's head-of-line blocking, solved with a bounded local queue decoupling one consumer process from many task executors, and deploy-triggered rebalance stalls still awaiting incremental cooperative rebalancing.

Interactive

Push a peak burst at RabbitMQ and watch Flow Control throttle your publishers into upstream latency while harakiri kills feed the broker more churn — then bounce it and pray. Try the HA mode that taxes throughput and jams its failovers. Escape to the Kafka MVP, watch 80% of the load leave in a week — then meet the new buffer's physics: one slow message stalling a partition, until the bounded local queue stalls one executor instead.

Open the visualization ↓

Problem

RabbitMQ and Celery were mission-critical, powering over 900 asynchronous tasks including order checkout, merchant order transmission, and Dasher location processing — and RabbitMQ was frequently going down under excessive load. When task processing went down, DoorDash effectively went down: orders could not be completed, costing merchants and Dashers revenue and consumers their dinner. The outages clustered at peak demand, and recovery meant restarting the system or standing up an entirely new broker and manually failing over.

The availability anatomy has four strands. Celery's countdown/ETA feature let engineers schedule tasks into the future, and heavy use parked that future work in the broker — load increases that directly preceded some outages, eventually resolved by restricting countdowns in favor of a separate scheduling system. Sudden traffic bursts left RabbitMQ in a degraded state where task consumption ran significantly below expectation, resolvable in their experience only by bouncing the broker; Flow Control — RabbitMQ's mechanism for slowing connections that publish too quickly so queues can keep up — was often though not always implicated, and when it kicked in, publishers experienced it as network latency, which during peak traffic cascaded as requests piled upstream. The uWSGI web workers ran harakiri, killing any process exceeding a timeout — so broker slowness triggered kills, kills triggered reconnection churn across thousands of workers, and churn loaded the broker further: the slowdown feeding itself. And separately, Celery consumers sometimes simply stopped processing absent any load or resource constraint, resuming on a bounce — never root-caused, suspected in the workers rather than the broker.

Scale offered no exit. They were on the largest single-node RabbitMQ available with nothing above it, and the primary-secondary HA mode reduced throughput through replication — trading headroom they couldn't spare — while in practice failovers took more than 20 minutes, often got stuck awaiting manual intervention, and lost messages along the way. Observability was thin on both halves (limited RabbitMQ metrics, opaque Celery workers), operations meant late-night manual failovers, and there were no in-house Celery or RabbitMQ experts to devise a scaling strategy. Engineering time spent keeping it alive was not sustainable.

900+
asynchronous tasks rode Celery and RabbitMQ — order checkout included; when task processing went down, DoorDash went down

Solution

The options table is the decision's honest record: swap Celery's broker to Redis (no horizontal scale in the mode they could use; doesn't fix stuck workers), swap to Kafka under Celery (unsupported), shard across multiple brokers (dilutes load, fixes nothing else — not observability, not churn, not stuck workers), upgrade versions (might help, guarantees nothing, demands a Python upgrade), or build a custom Kafka-backed system — more work than every other option, and the only one addressing every observed failure, harakiri churn and stuck consumers included, with in-house Kafka expertise to lean on. They chose the last, governed by three principles: hit the ground running (they liken it to swapping a fuel pump while driving the race car), design for seamless developer adoption, and roll out incrementally with zero downtime.

The MVP was deliberately minimal: producers publish a task's fully qualified name and pickled arguments to Kafka; consumers import the task by name and execute it synchronously. A wrapper around Celery's @task annotation routed each submission to either system via dynamically configurable feature flags — the same interface for both worlds, so adopting teams implemented exactly one flag and nothing else. A whitelist of compatible task parameters kept scope to the smallest feature set covering a majority of tasks. Two weeks of development put it in production; one week of ramp-up — lowest-risk, lowest-priority tasks first — cut RabbitMQ task load by 80%, and the repeated outages stopped almost as soon as rollout began. The migration machinery was priced candidly: dynamic flags at both submission and consumption sides meant the worker fleet ran at double capacity, half devoted to each system — taxing enough that a new Kubernetes cluster was spun up just to house workers — then wound down as topics transitioned and confidence grew. Remaining Celery features were ranked by the number of tasks using them; features used by few tasks were never implemented, those tasks rewritten instead, until everything had moved.

Then the post does what the class demands: it documents the new buffer's own backlog physics. Kafka partitions deliver in order to one consumer per group, so a single slow message stalls everything behind it in its partition — disastrous for a high-priority topic. The fix decouples fetching from execution: one Kafka-consumer process per worker feeds a local queue, bounded by a user-defined threshold, from which multiple task-execution processes draw — a slow message now stalls one executor while the partition keeps flowing, with the threshold capping the in-flight messages that a crash could lose. And deployments — several per day — trigger partition rebalancing that momentarily halts consumption despite per-topic consumer groups; tolerable for planned releases, potentially cascading for an emergency hotfix, with incremental cooperative rebalancing the desired cure once their client supports it. The wins ledger: outages stopped, task processing no longer the growth ceiling, granular metrics baked into every queue, worker, and task, and operations decentralized — templatized Terraform alerts, explicit owners for every topic and implicitly all 900+ tasks, day-to-day operations self-serve. The conclusion names its own doctrine: 80% of the result with 20% of the effort, a tactical mitigation buying time for the strategic solution.

2 weeks
from development start to the Kafka MVP running in production, scoped by a compatible-parameter whitelist
80%
of RabbitMQ task load migrated within one week of launch, lowest-risk tasks first — and the repeated outages stopped

Tradeoffs

  • A buffer's protective mechanisms can be the transmission line for its distress. Flow Control exists so queues can keep up — and its throttling reaches publishers as unexplained network latency, which at peak traffic cascades upstream as piled-up requests. The class thesis as mechanism: a buffer that responds to backlog by slowing its producers hasn't absorbed the pressure, it has exported it, invisibly, to the layer least equipped to interpret it.
  • Timeout-and-kill loops amplify what they're meant to contain. harakiri's contract — kill any process exceeding its timeout — is sane per-process and metastable per-fleet: broker slowness kills workers, thousands of restarted workers churn connections, connection churn is itself a documented RabbitMQ load source, and the slowdown deepens. Any remediation that responds to slowness by creating reconnection work must be audited for this loop before the incident, not during it.
  • High availability that taxes throughput can deliver neither. The primary-secondary HA mode paid replication's cost in reduced throughput — headroom they couldn't spare — and returned failovers that exceeded 20 minutes, stuck on manual intervention, losing messages. An availability mechanism is only worth its capacity tax if its failovers actually work; theirs converted 'we might go down' into 'we go down slower, with less headroom, and lose messages on the way back.'
  • The repair-versus-replace ruling turns on reachability, not preference. The options table rejects each repair for a stated gap — Redis couldn't scale horizontally in usable form, broker sharding fixed only dilution, version upgrades guaranteed nothing — and the custom Kafka build wins as the only option covering every observed failure, including the never-root-caused stuck consumers. With no in-house RabbitMQ expertise and opaque internals, the incumbent's pathologies weren't fixable from where they stood; the foundation swap was the reachable move.
  • Migration machinery has a price tag, and they printed it: dynamic switchability at both ends of the pipe meant running the worker fleet at double capacity — half per system, a whole new Kubernetes cluster for workers — bought as insurance for cluster maintenance, load shedding, and instant rollback. The complementary triage: rank every missing Celery feature by tasks-using-it, implement the head of the distribution, rewrite the tail. Two weeks to production, 80% of broker load gone a week later — the 80/20 arithmetic run on both the feature set and the migration itself.
  • The new buffer has new physics, documented with the same candor as the old one's. Kafka's in-order partitions mean one slow message stalls its whole partition — solved by decoupling fetch from execution through a bounded local queue (one stalled executor, a flowing partition, and a crash-loss exposure capped by the threshold). Deploys still trigger rebalance stalls, awaiting incremental cooperative rebalancing. Escaping buffer pathology isn't possible; choosing pathologies you can observe, bound, and live with is the actual win.

Patterns in this article

  • Fetch-Execute Decoupling

    Minted from the head-of-line fix: one Kafka-consumer process per worker fetches from its partitions into a bounded local queue, and multiple task-execution processes draw from it — so a slow message stalls a single executor while the partition keeps flowing, and the queue's threshold caps how many in-flight messages a crash can lose. Sibling boundary with Selective Acknowledgment, minted from Uber's answer to the identical problem: Uber's proxy preserves delivery guarantees with out-of-order acks and a DLQ; DoorDash's local queue accepts a bounded loss window for simplicity — two prices for unblocking the same head-of-line.

  • Universal Staged Rollout

    Fifth consecutive migration carrying the pattern, contributing its cost ledger and its triage rule: dynamic feature flags at both the submission and consumption ends bought instant reversibility and cluster-switching — priced at a worker fleet running double capacity, one new Kubernetes cluster included — while missing features were ranked by tasks-using-them, the head implemented, the tail rewritten. Two weeks to an MVP in production, lowest-risk tasks first, 80% of broker load migrated within a week of launch.

Also solving this

Other systems in behindscale's Buffer degrades under backlog class: