Limited Time Offer:Up to 0% off Hello Interview Premium
Up to 0% off Hello Interview Premium 🎉
Hello Interview
Your Dashboard
System Design
Code
Low Level Design
Behavioral
AI Coding
New
ML System Design
Salary Negotiation
Interview Guides
Blog
System Design
Low Level Design
AI Coding
Behavioral
Code Review
New
Interview Questions
Success Stories
System Design
Low-Level Design
New
Ask The Community
Discord
Quizzes
Refer a Friend
Pricing
Sign in / Sign up
Search
⌘K
Pricing

Tutor

Get Premium
In the Wild

How Slack Put Kafka in Front of Its Redis Job Queue

Managing Long Running Tasks
Scaling Writes
ByEvan King·Published

Read the Source Blog

Originally published by Slack Engineering on December 6, 2017

View

The TLDR

On its busiest days, Slack’s async job queue handles more than 1.4 billion jobs, peaking at 33,000 per second. It powers nearly everything too slow for a web request, including posting messages, sending push notifications, generating URL unfurls, triggering calendar reminders, and running billing calculations.
That queue was built entirely in Redis, holding both the backlog and the dispatch bookkeeping until a database slowdown made jobs pile up until Redis hit its memory limit. At that point, the queue could neither accept new jobs nor, since dequeuing also needed a little free memory, hand out the old ones.
Slack kept the fix narrow. They added Kafka in front of Redis as a durable buffer plus two new Go services to move jobs in and out, keeping the application's queue logic untouched. As a result, a backlog now piles up on disk while operators throttle the flow into Redis.
We picked Slack's write-up because it's the clearest production account we know of putting a durable log in front of a fast dispatch layer.

The problem

The whole queue lives in RAM

Slack's job queue runs on a fleet of Redis clusters, fifty of them by the time of this story, with pools of worker machines polling those clusters for new work. When something in Slack needs to happen later, the web app builds a job identifier from the job type and its arguments, then hashes it with the logical queue name to pick which Redis host the job lands on. That host runs limited deduplication, discarding the request if an identical identifier is already waiting. Workers pull jobs off the pending queue, moving each onto an in-flight list before spawning an async task to run it. Finished jobs come off the list, and failures retry until they land in a permanently-failed list that humans repair by hand.
Slack doesn't say which Redis structures they used or exactly why dequeuing cost grew with queue length, but a typical Redis task queue shows how that happens. Waiting jobs sit in a list per logical queue that enqueues push onto. Deduplication needs an index you can check by job ID, a set or a hash maintained alongside the pending jobs. Moving a job to an in-flight list before execution means it remains recoverable if the worker dies. RPOPLPUSH makes that handoff cheap, popping the oldest pending job and pushing it onto another list at constant cost. Removing one particular job by value is a different operation, a command like LREM that scans the list end to end, and a scan like that on every dequeue produces the linear cost Slack described.
Jobs run from a few milliseconds to several minutes, and the queue had scaled from Slack's earliest days without the core architecture changing. The same finite pool of RAM held both the buffer that absorbs bursts of new work and the working space where dispatch, dedup, and retries get tracked.

Why the queue stayed down after the database recovered

Resource contention in the database layer slowed job execution, so jobs accumulated in Redis until it hit its configured maximum memory. Enqueues began failing, and with them every Slack feature that depended on the queue. The dequeue path then prevented the queue from recovering. Pulling a job off the queue moves it onto a processing list, and that move needs free memory a full Redis doesn't have, so even after the database recovered the queue stayed stuck, and it took extensive manual intervention to revive.
Memory exhaustion was only one of the problems the post-mortem turned up. Every job-queue client connected to every Redis instance, a complete bipartite graph. Workers couldn't be scaled independently either, because each added worker adds polling load on Redis, so adding execution capacity could push an already overloaded Redis further under. The linear-cost dequeue made a second feedback loop, since a longer queue slows every dequeue at the moment draining matters most. And because the queue's exact semantics were never pinned down, engineers hesitated to build on it or to change dedup behavior that many jobs depended on.

The solution

Kafka in front

The post-mortem set the requirements. The backlog had to live somewhere durable that couldn't run out of memory whether jobs flooded in or drained too slowly, scheduling needed real rate limits and priorities, and execution capacity had to scale without adding polling load on Redis. Slack put Kafka in front of Redis instead of replacing Redis with it. Why run two queue-shaped systems? Why not just make Kafka the queue?
Slack could have rebuilt the queue directly on Kafka. It's a durable append-only log, split into partitions, where each consumer tracks its own read position as an offset, and the log lives on disk, bounded by a retention window rather than by RAM, so a burst of enqueues just makes the log longer. An offset is a position in a partition, not per-job state, so per-job acknowledgment, retries, dedup, and in-flight tracking have to be built on top, retries as re-enqueues onto the topic, dedup as a small keyed store, and plenty of teams run job systems on Kafka in exactly this way. Slack had all of those semantics already built in Redis, with years of application code depending on their exact behavior, so rebuilding on Kafka alone would have turned a narrowly scoped availability fix into a rewrite.
RabbitMQ, SQS, and beanstalkd all persisted to disk, and all had the same problem, since each brings its own delivery semantics and adopting one meant the same rewrite against a different API. Turning on Redis persistence wouldn't have helped either, because a Redis dataset has to fit in RAM whether or not it's also written to disk, and the outage was a capacity problem, not a durability one. And keeping Redis alone was the design that had just failed, where the burst buffer and the dispatch workspace compete for the same RAM and overload ends in lockup rather than lag.
So Slack made what they called the "minimum viable change". The web app enqueues into Kafka, which durably buffers everything, a relay drains Kafka into Redis at a controlled rate, and Redis keeps providing the short queue and the semantics the application understands. Neither the enqueue interface nor the worker side changed. Slack first protected the enqueue path, then planned to revisit scheduling and worker scaling after running the new path in production.

Kafkagate and JQRelay

Slack wrote two new stateless Go services to run the new path. Kafkagate is the enqueue path from the web app into Kafka. The interface is a plain HTTP POST, simplified here:
POST /enqueue
{
  "topic": "jobs-cluster-07",
  "partition": 12,
  "content": { "type": "url_unfurl", "args": { ... } }
}
Kafkagate holds persistent broker connections, follows partition leadership as it moves, and writes synchronously, so the caller always gets a positive ack or an error. It waits only for the partition leader to acknowledge a write, not for replication, taking the lowest latency in exchange for a small chance of losing a job if a broker dies before replicating, a tradeoff Slack judged right for most jobs. Enqueuing hosts prefer a Kafkagate in their own availability zone, failing over to others when needed. This also retired the enqueue half of the connection fan-out, since the web app now talks to a nearby gateway instead of holding connections to fifty Redis clusters.
JQRelay is the drain, one instance relaying one Kafka topic into its corresponding Redis cluster. Coordination runs through Consul, a service that provides distributed locks. On startup an instance tries to acquire the lock for a topic, and if it wins it relays all of that topic's partitions. If it ever loses the lock, it releases everything and restarts so another instance can take over, and since instances run in an auto-scaling group, replacements go through the same flow. The result is one relay responsible for each topic.
JQRelay tracks progress with Kafka's per-partition commit offsets, and the core loop, simplified, advances an offset only after the job is safely in Redis:
for job in consume(topic, partition):
    write_to_redis(job)       # retry indefinitely while Redis is down
    commit_offset(partition)  # only after the Redis write succeeds
If Redis is down, the relay retries until it returns or is replaced while the log absorbs new work behind it. If a specific job errors, JQRelay re-enqueues it to Kafka, which keeps the queue moving without losing the job. The rate limits live in Consul, picked up through its watch API. During a buildup the web app keeps enqueueing at full speed while operators turn the relay down to match what workers can execute.

Proving it with production traffic

The cluster ran 16 brokers, with 32 partitions per topic, replication factor 3, two days of retention, and rack-aware replication mapped to availability zones. Unclean leader election, where Kafka accepts a stale replica as leader to restore availability at the risk of losing acknowledged writes, was enabled. The team sized the cluster with load tests at expected production rates, then ran failure drills covering broker loss, a forced unclean leader election, and a full cluster restart, and every scenario met their availability goals.
The rollout itself started with double writes, every job going to both the old Redis path and the new Kafka path while JQRelay ran in what they called "shadow" mode, reading everything from Kafka and dropping it, which put real production traffic through every new component with zero user impact. They counted jobs at each hop, web app to Kafkagate to Kafka to Redis, and ran heartbeat canaries, one per partition per minute across all 1,600 partitions (50 topics of 32, one per Redis cluster), with alerts on end-to-end flow and timing. The new path served Slack internally for a few weeks before rolling out to customers one job type at a time.
One snag came from putting a second runtime into a PHP-only pipeline. JQRelay decodes and re-encodes each job's JSON, and Go by default escapes <, >, and & into unicode entities while PHP escapes /. The same data structure produced different bytes depending on the runtime, and it caused real trouble, "heartache" in the team's own word.

What generalizes

Slack's outage began when enqueues exceeded dequeues long enough for the backlog to fill Redis, and some dependency will eventually slow down the same way in any system at this volume. An in-memory queue works while its worst-case backlog remains comfortably below available memory. It's fast and the job semantics are easy to build, but the backlog, the dedup index, and the in-flight bookkeeping all live in one bounded pool of RAM with no durable backstop, and once a backlog outgrows that pool the queue can neither accept new work nor hand out old work. Putting Kafka in front moves the long backlog out of Redis and onto disk, where operators can slow the relay into Redis until workers catch up. Redis no longer has to hold the entire buildup. The throttle has a second payoff. Since dequeue cost grew with queue length, keeping the Redis-resident queue short also keeps every dequeue cheap.
Redis remained the scheduler and dedup layer, workers still poll it, so the worker half of the connection fan-out survived, and the relay's rate limits were adjusted by hand, with tying them to Redis memory listed as future work. The first phase kept enqueues available during a backlog. They also chose availability over durability twice, in leader-only acks and unclean leader election, which is coherent for a workload where losing a rare job beats refusing all jobs.
Slack was still describing the system the same way in 2023, Kafka for durable buffering, Redis for short-term dispatch state, workers for execution, by then calling it their asynchronous compute platform and running roughly 9 billion jobs a day through it, more than six times the volume in the 2017 post. Whether pieces like Kafkagate and JQRelay survived unchanged isn't something Slack has documented, but a 2026 talk from a Slack infrastructure engineer put the platform at 10 billion jobs a day and still described it as queues with no scheduler in front. When Slack needed real scheduling for cron workloads, it came as a separate Go service on their Kubernetes platform that handles the timing and enqueues into this same queue. The new scheduler on the 2017 post-mortem's wishlist apparently never materialized, nine years on.
That assembly was fairly novel in 2017. Today teams are more likely to begin with a durable broker than to keep their entire backlog in an in-memory queue, and few would build Slack's exact Kafka-to-Redis bridge by hand. Kafka with consumer groups serves as the backbone of plenty of job systems directly. For many ordinary job queues, SQS combines durable buffering with acknowledgment, retries, and dead-letter handling, though application-specific scheduling, prioritization, and dedup may still sit elsewhere. Durable-execution engines like Temporal go further and persist every step of a job, not just the fact that it was enqueued. And Redis itself added Streams a year after this post, a log structure with consumer groups and acknowledgments, although in a conventional in-memory deployment the data still has to fit in RAM, the same limit Slack hit.
Read the original at Slack Engineering

Mark as read

Your account is free and you can post anonymously if you choose.

Currently up to 20% off

Hello Interview Premium

System Design Guided Practice
Exclusive content
Recent interview questions
Learn More
Reading Progress

On This Page

The TLDR

The problem

The whole queue lives in RAM

Why the queue stayed down after the database recovered

The solution

Kafka in front

Kafkagate and JQRelay

Proving it with production traffic

What generalizes

Questions
Meta SWE Interview QuestionsAmazon SWE Interview QuestionsGoogle SWE Interview QuestionsOpenAI SWE Interview QuestionsAnthropic SWE Interview QuestionsEngineering Manager (EM) Interview Questions
Learn
Learn System DesignLearn DSALearn BehavioralLearn ML System DesignLearn Low Level DesignGuided Practice
Links
FAQPricingGift PremiumHello Interview Premium
Legal
Terms and ConditionsPrivacy PolicySecurity
Contact
About UsProduct Support

7511 Greenwood Ave North Unit #4238 Seattle WA 98103


© 2026 Optick Labs Inc. All rights reserved.

Login to track your progress