The Queue That Couldn't Drain: Kafka in Front of Redis at Slack

Slack's job queue seized in production and stayed seized even after the root cause was fixed: a full Redis needed a little free memory to dequeue, so the fuller the queue got, the less it could drain. That queue runs everything too slow to do inside a web request - every message post, push notification, link unfurl, and billing calculation, 1.4 billion jobs on a busy day at 33,000 per second - and the outage began when database contention slowed job execution and Redis filled to its memory limit. The redesign made the smallest change that removed the failure mode rather than a ground-up rewrite: put Kafka in front of Redis as a durable buffer. A stateless Go gateway (Kafkagate) writes each job to Kafka the moment it arrives, and a relay (JQRelay) feeds jobs into Redis only as fast as workers can drain them, so backlog now lands on disk instead of in the memory the drain depends on. It was rolled out carefully: double-writes, job counts checked at every hop, and heartbeat jobs sent through every partition.

Interactive

Slow the workers and watch the old Redis queue fill, seize, and stay seized until you intervene by hand — then flip to the Kafka-fronted system and watch the same slowdown become a backlog on disk instead of an outage.

Open the visualization ↓

Problem

Slack's job queue is the asynchronous half of the product: work too time-consuming to finish inside a web request - every message post, push notification, link unfurl, calendar reminder, and billing calculation - flows through it, over 1.4 billion jobs on the busiest days at a peak of 33,000 per second. The original design was the classic Redis task queue, essentially unchanged through orders of magnitude of growth: the web app hashes a job's identifier to one of the Redis hosts, checks it against jobs already queued to avoid duplicates, and pools of workers poll for work, moving each job to an in-flight list while it runs and to a retry queue if it fails.

The outage that forced a rethink began outside the queue: resource contention in the database layer slowed job execution. Workers now drained slower than the web app enqueued, and Redis climbed to its configured memory limit. At that point no new jobs could be enqueued, and every Slack operation that depends on the queue began failing. The deeper trap was on the drain side: dequeuing a job moves it into a processing list, which needs a little free memory. A full Redis therefore could not empty itself. Even once the database contention was resolved, the queue stayed locked, and recovery took extensive manual work.

WHY A FULL QUEUE SEIZES
A full Redis has no room to dequeue, so it cannot drain
Dequeuing a job needs a little free memory to move it aside. When the backlog fills Redis to its limit, that room is gone, so the full queue cannot empty itself, and stays locked even after the slowdown is fixed.

The post-mortem concluded that scaling the existing system was untenable, and named the constraints plainly. Redis had little room to spare, especially in memory, so a sustained period of enqueuing faster than dequeuing ends in exactly the deadlock above. Dequeue cost was proportional to queue length, thanks to earlier data-structure choices, so longer queues were harder to empty - the post's own "unfortunate feedback loop." Workers couldn't scale independently of Redis: every added worker added polling load, so trying to add execution capacity could overwhelm an already struggling Redis (a second feedback loop). Every enqueuer also had to track every Redis instance, a full mesh of connections that grew on both sides at once. And the queue's delivery guarantees were vague enough that engineers were reluctant to lean on a system already fundamental to the architecture.

1.4 billion
jobs on the busiest days, peaking at 33,000 per second
Grows with length
dequeue cost in the old system: the longer the queue, the more each dequeue costs, so a deep backlog is even harder to drain

Solution

The team weighed a ground-up rewrite against an incremental change and chose the smallest change that removed the existential failure mode: put Kafka in front of Redis instead of replacing Redis outright. Replacing Redis would have meant rewriting the scheduling, execution, and deduplication logic built on it; adding a durable buffer in front left the application's enqueue and dequeue interfaces intact while ending the era of backlog living in the very memory the drain depends on.

Two stateless Go services sit on either side of the new tier. The gateway, Kafkagate, takes a simple HTTP POST from the PHP/Hacklang web app and writes it into Kafka. Its design states its bets plainly. Kafka copies each message to several brokers for safety; Kafkagate waits only for the lead broker to acknowledge the write, not for the copies, which trades a small risk of loss if that broker dies at the wrong moment for the lowest possible enqueue latency. The post judges that availability-over-consistency bias right for most of Slack's jobs, with stronger guarantees left open as an option for critical ones. The write is synchronous, so the web app gets back a clear success or error, tightening a guarantee engineers had found hard to trust. To cut latency and cost, requests prefer a Kafkagate instance in the same availability zone, while still failing over across zones when needed.

The relay, JQRelay, drains Kafka into Redis, and it is where the queue's new discipline lives. Each relay instance takes a lock, held in Consul (a coordination service the fleet uses to agree on who holds what), for one Kafka topic. That lock plus an auto-scaling group guarantees exactly one relay per topic, healing itself if one fails. It advances Kafka's commit offset only after a job is safely written to Redis, retrying indefinitely through Redis outages, and re-enqueues a failed job back to Kafka rather than dropping it, so a job that keeps failing can neither block the queue nor vanish. Crucially, JQRelay writes to Redis under rate limits configured in Consul: this is the admission valve that keeps Redis inside its memory headroom no matter how fast the world enqueues. The Kafka tier itself: 16 brokers on i3.2xlarge, 50 topics of 32 partitions each, replication factor 3, two-day retention, spread across availability zones.

The rollout matched the caution of the design. Double-writes sent every job to both systems while JQRelay ran in shadow mode, exercising the full new path on real traffic without executing anything. Correctness was checked by counting jobs at every hop - web app to Kafkagate, Kafkagate to Kafka, Kafka to Redis - and by heartbeat jobs enqueued every minute into each of the 1,600 partitions, alerting on end-to-end flow and timing. Failure testing killed brokers one at a time, in pairs within a zone, and all three copies at once to force Kafka to promote a broker that might be missing recent writes. Then Slack ran the system on itself for weeks before rolling it out one job type at a time. The result rewrites the outage: a build-up now lands in durable storage while enqueues keep succeeding, and the fix is turning a rate-limit dial rather than paging humans to unstick a seized Redis.

1,600
Kafka partitions (50 topics × 32), each receiving a heartbeat canary every minute during rollout

Tradeoffs

  • The smallest safe change bought safety at the price of a permanently taller stack. Kafka in front of Redis means two queueing systems, two failure domains, and two operational surfaces where a rewrite would have left one, plus Kafkagate and JQRelay to run alongside. The post is candid that this was sequencing, not the destination: the scheduler and execution layers still ride on Redis, and the larger goals are left to future work. Incrementalism converted an existential risk into an ongoing carrying cost.
  • Kafkagate's leader-only acknowledgment is a chosen loss window, stated plainly. Wait for the copies and every enqueue pays the latency; acknowledge on the lead broker alone and a broker dying at the wrong moment loses jobs. Slack judged the fast path right for most of its jobs and left stronger consistency as an option for critical ones, so delivery is guaranteed by the durable tier and honestly caveated at its front door.
  • Rate-limited admission moves the failure, it doesn't abolish it. Redis can no longer seize, but if execution stays slow the backlog now piles up in Kafka against a two-day retention window, and someone still has to decide what a sustainable drain rate is. The rate limits live in Consul precisely because they are an operational judgment, not a solved constant. The deadlock became a dial.
  • Putting two languages in the pipeline added a tax the old single-language system never paid. The moment Go sat between PHP producing JSON and PHP consuming it, encoder quirks (Go escaping the characters <, >, and & to unicode entities; PHP escaping slashes) made identical data serialize differently, and the team paid in debugging time for a seam that hadn't existed before. Every language boundary added to a pipeline is a compatibility contract nobody wrote down.
  • One relay per topic, enforced by a Consul lock, buys correctness with a bottleneck: a topic's throughput into Redis is capped by its single relay, and failover means lock churn and a restart rather than a seamless handoff. The auto-scaling group heals failures, but the design accepts brief per-topic stalls as the price of never running two relays on the same partition at once.
  • The rollout's rigor (double-writes, job counts at every hop, 1,600 heartbeat jobs a minute, deliberately killing brokers) was itself a cost the team chose to pay, not an overhead they suffered. It is the same lesson Notion's migration taught from the database side: the verification machinery around a live-system change is engineering work of the same rank as the change itself, and skipping it is how a small safe change becomes a maximum-blast-radius incident.

Patterns in this article

  • Durable Front Buffer

    The article's central move: stop using the in-memory store as the shock absorber and put durable storage in that role instead. Kafka accepts every job the instant it arrives, at full speed, while the relay feeds jobs into Redis only as fast as the workers can drain them. Ingestion stays available no matter how deep the backlog gets, and the fragile execution tier is only ever fed at the rate it can handle.

  • Queue with Guaranteed Delivery

    Delivery is guaranteed by never losing a job silently: the relay advances Kafka's position only after a job is safely in Redis, and re-enqueues a failed job instead of dropping it, so loss turns into delay. Two other articles here (Discord and Meta) make the same trade. The honest exception is at the front door, where the gateway acknowledges a write as soon as the lead Kafka broker has it, accepting a small, documented chance of loss in exchange for speed.

Also solving this

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