Scaling Celery on Redis: Where the Broker Breaks and What to Do About It
Redis becomes a Celery bottleneck because it runs every command on one CPU core. Your broker handles each LPUSH, each BRPOP, each acknowledgment, and each visibility timeout check on that single thread. On a 16 or 64 core machine you are paying for every core and using one. Past that ceiling you either shard Redis or move to a broker that uses the cores you already have.
Most Celery deployments never hit this. If yours has, the symptoms are specific and this guide covers how to confirm it, what to fix in configuration first, and what to do when configuration is not the problem.
How do Celery and Redis work together?
Celery is a producer-consumer system. Your application serializes a task into a message and pushes it onto a queue. A worker process pulls the message and executes it. The broker stores messages and routes them.
Redis implements the queue as a list. Producers call LPUSH, workers call BRPOP. Redis also holds task metadata, result backends, and lock keys used for deduplication. Every one of those operations runs on the same thread.
At moderate throughput this is fine, and the per-message overhead is small. It stops being fine when queue depth grows, worker count increases, or payloads get larger.
Where does Redis hit its ceiling as a Celery broker?
Three places, in the order you will encounter them.
CPU. Redis processes commands on one core. Publishing, consuming, storing results, and checking visibility timeouts all contend for it. You notice this as elevated P99 on task delivery before you notice it as anything else.
Memory. Celery task messages are small, roughly 300 to 800 bytes, and structurally repetitive: the same JSON keys, the same task names, the same header shape. Individually they compress badly. A queue backed up to 500,000 pending tasks is hundreds of megabytes of near-identical structure stored uncompressed.
Operations. Scaling past one Redis instance means Cluster or Sentinel: slot-based sharding, cross-node redirects, resharding runbooks, and a new set of failure modes on-call has to learn. For a component whose job is holding messages temporarily, that is a lot of surface area.
What Celery settings should you fix before touching infrastructure?
Configuration causes more production incidents than broker capacity does. Get these right first, because a broker migration will not fix any of them.
Visibility timeout
visibility_timeout controls how long the broker waits before redelivering an unacknowledged task. Set it longer than your longest task, plus buffer. A 10 minute worst case wants a 15 minute timeout.
Too low and you get duplicate execution. Too high and recovery after a worker crash drags. The subtle failure: if any task uses countdown or eta longer than the visibility timeout, the broker redelivers it before its scheduled run time, and you get duplicates that look like a code bug.
Late acknowledgment and prefetch
unknown nodetask_acks_late acknowledges a task only after the worker finishes it. A worker that crashes mid-execution returns the task to the queue instead of losing it silently. Non-negotiable for anything touching payments, user data, or external APIs.
worker_prefetch_multiplier = 1 limits each worker to reserving one task at a time. Higher values let a worker reserve five tasks while still working the first, which starves other workers. Keep it at 1 for long-running or mixed workloads. Raise it to 4 or 8 only when tasks are uniform and consistently under 100ms.
Connection resilience
Network blips happen. Configure retries so brief outages do not drop tasks:
unknown nodeTCP keepalive matters here. Without it, stale connections surface as silent task loss rather than as errors.
How should you structure queues for high throughput?
Routing everything through one default queue is the fastest path to an incident. A slow report task and a fast email task in the same queue means the report blocks workers that could be clearing email.
Split by execution profile
Group tasks by what they do with the CPU:
- I/O-bound (API calls, email, webhooks): gevent workers, 100 to 500 greenlets
- CPU-bound (image processing, PDF generation, aggregation): prefork workers, concurrency matching core count
- Critical path (payments, auth, notifications): dedicated queue, dedicated workers
Route them explicitly in config, then start a worker per queue with the matching pool type. A stuck report task should never be able to delay a password reset.
Send Celery Beat tasks to their own queue as well, so scheduled work does not contend with user-triggered work.
Priority and rate limiting
Use priority routing for tasks that need to jump the line, and rate limiting on non-critical queues so a bursty producer cannot saturate the broker. Priority in Redis-brokered Celery is approximate, not strict ordering. Treat it as a hint.
Which concurrency model should your workers use?
Pool | Best for | Concurrency | Watch out for |
|---|---|---|---|
| CPU-bound work | = CPU cores | High memory per worker |
| I/O-bound work | 100 to 500 | CPU-bound code blocks the whole event loop |
| Mixed I/O and short CPU bursts | 10 to 50 | GIL limits real CPU parallelism |
Picking the wrong one is the most common performance mistake in Celery deployments, and it looks like a broker problem from the outside.
Set worker_max_tasks_per_child = 1000 to recycle processes and keep memory leaks from accumulating. Watch worker memory over time and lower it if you see steady growth.
For autoscaling, --autoscale=max,min adjusts worker count against queue depth. On Kubernetes, pair it with HPA rules keyed to queue length per pod rather than CPU, since a worker waiting on I/O looks idle to the HPA.
How do you tell whether the broker is actually your bottleneck?
This matters because the fix is expensive and the symptoms overlap with worker misconfiguration.
Broker saturation looks like:
- Redis CPU consistently above 80% on one core while other cores idle
- Queue depth growing during peak and not draining after
- Workers idle while the queue is full
- P99 publish latency climbing while P50 stays flat
If CPU is low and queue depth still grows, you are under-provisioned on workers, not on broker. If one queue backs up while others drain, it is queue design. Rule both out before concluding the broker is the constraint.
What to monitor
Queue depth per queue, tasks published and consumed per second, broker round-trip latency, worker utilization, and task failure rate.
Flower gives you real-time worker and queue state: celery -A project flower --port=5555. Keep it on a private network or behind an SSH tunnel. It exposes revoke and worker management actions, so treat it as an internal ops tool, not a dashboard you leave open.
When is Redis fine and this whole exercise unnecessary?
Most Celery deployments never reach the single-threaded ceiling. Redis is fine when:
- Sustained throughput is in the low thousands of tasks per second
- Queues drain during off-peak instead of growing week over week
- Redis CPU sits well under 80% during your worst hour
- Your incidents trace to task code, worker sizing, or queue design
Redis has decades of operational knowledge behind it, a deep client ecosystem, and a large bench of engineers who can debug it at 3 AM. That is worth something real. If your broker is not the constraint, migrating it is not your highest-leverage optimization, and the configuration and queue-design work above will do more for you.
The rest of this is for teams that have ruled out the alternatives and are genuinely core-bound.
How does Dragonfly change the broker equation?
Dragonfly is wire-compatible with Redis. You point broker_url at it. No library swap, no code changes, no configuration rewrite.
The difference is architectural. Dragonfly uses a shared-nothing, thread-per-core design, so command execution distributes across every available core instead of serializing on one. In Dragonfly's benchmarks this sustains over 8 million queue operations per second on a single instance, so a single node covers workloads that would otherwise need a multi-node Redis Cluster, which removes the sharding and resharding surface area rather than managing it.
Queue memory compression
This is the part specific to Celery, and it exists because of exactly the property that makes Celery messages compress badly on their own: they are small and they share a fixed schema.
Dragonfly can train a ZSTD compression dictionary on a list once that list crosses a size threshold, then compress every subsequent entry against that dictionary. Because the schema is a contract between your producers and consumers, it stays stable, which is what makes the trained dictionary keep paying off.
Enable it by setting list_compress_dict_threshold to a non-zero byte value. Dragonfly's testing used 16 KiB:
In Dragonfly's benchmark, a Celery producer pushed bursts of 100K messages at roughly 500 bytes each with randomized values and a shared schema, while a consumer drained the queue every few seconds. Memory climbed past 1 GiB with compression off. With it on at a 16 KiB threshold, it stayed under 300 MiB for most of the run. Sidekiq under the same setup peaked near 250 MiB against the same 1 GiB baseline.
Reads are transparent. Dragonfly decompresses internally before handing data to the client, so Celery neither knows nor cares.
Three caveats worth knowing before you turn it on:
- The dictionary is trained once and stays fixed for the process lifetime. Dragonfly screens the training list for size and estimated compressibility so it does not train on a bad sample, but it does not retrain later.
- Gains depend on schema uniformity. Lists with genuinely varied content share too little state with the dictionary to benefit, and Dragonfly skips compression when it would not help.
- The benchmark above is a synthetic producer-consumer workload, not a production deployment. Your ratio depends on your message shape and queue behavior.
Consolidating roles
Dragonfly works as broker, result backend, distributed lock manager for deduplication, and cache layer. SET NX EX behaves identically to Redis, so existing locking code moves without changes. Fewer systems to monitor is a real operational win, though it also concentrates your blast radius, which is worth deciding deliberately rather than by default.
How do you migrate from Redis to Dragonfly?
Deploy alongside. Run Dragonfly via Docker, Kubernetes, or the binary, with the same port and auth settings your Redis instance uses. Set list_compress_dict_threshold=16384 if your queues are large enough to benefit.
Repoint Celery. Update broker_url and result_backend. Task definitions, queue routing, and worker startup commands do not change. Move lock and cache connections too if you are consolidating.
Validate. Run your test suite against the Dragonfly-backed deployment. Compare broker latency, memory, and queue depth against your Redis baseline under real load, not synthetic load. If you were previously capacity-planning around a single-core ceiling, re-plan against measured headroom rather than the 8 million operations per second figure, which is a benchmark maximum and not a promise about your workload. Verify compression is working by watching memory against queue depth, since the dictionary only trains once a list crosses the threshold and you will see nothing until it does.
Redis, KeyDB, or Dragonfly?
Capability | Redis | KeyDB | Dragonfly |
|---|---|---|---|
Concurrency | Single-threaded | Multi-threaded I/O on the Redis codebase | Shared-nothing, thread-per-core |
Peak queue ops/sec (benchmark) | Bounded by one core | Higher than Redis, gated by serialization points | Over 8 million on a single instance |
Scaling past one node | Cluster or Sentinel | Cluster or Sentinel | Usually unnecessary |
Celery list compression | No | No | Dictionary-based, opt-in |
Ecosystem maturity | Deepest | Smaller | Growing |
Redis is the default for good reason and the operational knowledge behind it is genuinely deep. KeyDB adds multi-threaded I/O, which helps read-heavy workloads more than the constant LPUSH/BRPOP cycle a broker generates, since command execution still has serialization points. Dragonfly was built multi-threaded from the start, so both I/O and command processing scale with core count.
Where to start
Fix configuration first: visibility timeout, task_acks_late, prefetch, connection retries. Then queue architecture: split by execution profile, match pool types, isolate the critical path. Then measure, and confirm the broker is actually your constraint rather than assuming it.
If you have done all three and you are genuinely core-bound on Redis, Dragonfly is a connection string change rather than a rearchitecture. The dictionary compression writeup has the full benchmark methodology, and the getting-started guide covers Docker and Kubernetes deployment.
Sources
- Dragonfly vs Redis benchmarks (throughput and memory figures): dragonflydb.io/dragonfly-vs-redis
- Dictionary compression for Celery and Sidekiq queues, including full benchmark methodology: Dragonfly engineering blog
- Dragonfly getting started: dragonflydb.io/docs/getting-started
Benchmark note: the memory figures come from Dragonfly's Celery and Sidekiq compression tests, run with a synthetic producer-consumer pair rather than a production deployment. Throughput figures are benchmark maximums on Dragonfly's published test hardware and should be treated as a ceiling, not a projection for a specific workload.
FAQ
What is the best broker for Celery at high throughput?
For high-throughput Python task queues, Dragonfly sustains over 8 million queue operations per second on a single instance in Dragonfly's benchmarks, while staying wire-compatible with Celery's Redis broker protocol. Switching is a connection string change. Whether you need that ceiling is a separate question: see the section on when Redis is fine.
Why does Redis slow down as a Celery broker under load?
Redis executes all commands on a single CPU core. As task volume rises, that core saturates and publish and consume latency climbs. Queue depth grows because workers cannot pull as fast as producers push, regardless of how many workers you add.
How do you prevent duplicate task execution in Celery?
Combine task_acks_late = True with idempotent task design and a lock, either Celery Once or a manual SET NX EX. Redis-brokered Celery is at-least-once delivery, so idempotency is a requirement rather than an optimization.
What is visibility timeout in Celery with a Redis broker?
How long the broker waits before redelivering an unacknowledged task. Set it longer than your longest-running task. Too short causes duplicates, too long delays recovery after a worker crash. Watch for countdown and eta values that exceed it.
How does Dragonfly reduce Celery queue memory usage?
It trains a ZSTD dictionary on the shared schema of your task messages once a list crosses a byte threshold, then compresses subsequent entries against it. In Dragonfly's Celery benchmark this took peak memory from just over 1 GiB to under 300 MiB. Decompression is internal, so no application changes are needed.
Can Dragonfly be both Celery broker and result backend?
Yes. It supports the data structures Celery uses for queuing, result storage, and locking. Point broker_url and result_backend at the same instance.
How should you split Celery tasks across queues?
By execution profile. I/O-bound work to gevent workers at high concurrency, CPU-bound work to prefork workers matched to core count, critical-path work to its own queue with its own workers.
What should you monitor for broker health?
Queue depth, tasks published and consumed per second, broker latency, worker utilization, and failure rate. Alert on sustained queue growth that does not drain off-peak, which is the earliest reliable signal of saturation.