What Is a Redis Queue?
A Redis queue is a work queue built on top of a Redis data structure — usually a list, sometimes a sorted set or a stream. Producers append tasks, consumers take them off in order, and the two sides never have to be online at the same time. It is one of the most common things Redis is used for after caching, and one of the easiest to get subtly wrong.
The thing worth knowing up front: Redis has no queue data type. There is no QUEUE object, no broker process, no delivery bookkeeping. What Redis gives you is a set of primitives with the right performance characteristics, and the queue semantics are yours to build. Everything below is about which primitive to pick and what you have to add to it.
How a Redis Queue Works
The standard construction is a Redis list used as a FIFO buffer. A producer pushes onto one end, a consumer pops from the other:
> LPUSH tasks "{\"job\":\"resize\",\"id\":41}"
(integer) 1
> RPOP tasks
"{\"job\":\"resize\",\"id\":41}"
Pushing left and popping right gives first-in-first-out. Pushing and popping the same end gives you a stack instead — a distinction that matters more than it looks, because it is a single letter in your code and there is nothing in Redis that will warn you.
| Command | What it does | Complexity |
|---|---|---|
LPUSH / RPUSH | Append to the head or tail | O(1) per element |
LPOP / RPOP | Remove and return from the head or tail | O(1) without COUNT |
BLPOP / BRPOP | The same, but block until an element arrives or the timeout expires | O(N) in the number of keys given |
LMOVE | Atomically pop from one list and push to another | O(1) |
BLMOVE | Blocking LMOVE | O(1) |
LMPOP / BLMPOP | Pop multiple elements, or from the first non-empty of several lists (Redis 7.0+) | O(N+M), keys and elements returned |
LLEN | Queue depth — the metric to alert on | O(1) |
Block, Don't Poll
The most common mistake in a first implementation is a consumer that loops on RPOP with a sleep. It burns CPU on both ends when the queue is empty and adds up to a full sleep interval of latency when it is not.
BRPOP solves both. The connection parks server-side until an element arrives, and Redis hands it to the longest-waiting blocked client the instant a producer pushes. Latency drops to a network round trip and idle cost goes to nearly zero.
> BRPOP tasks 30
1) "tasks"
2) "{\"job\":\"resize\",\"id\":41}"
Two things to plan for. A blocked connection is occupied, so consumers need their own connections rather than sharing the one your application uses for cache reads. And you should always pass a timeout rather than blocking forever, so a consumer that has lost its server gets a chance to notice.
Lists vs. Streams vs. Pub/Sub
Lists are the default choice, not the only one. Redis offers three primitives that look interchangeable in a diagram and behave very differently in production. Picking wrong here is the most expensive mistake in this article, because it is the hardest to undo once messages are flowing.
| List | Stream | Pub/Sub | |
|---|---|---|---|
| Delivery | At-most-once (message leaves on pop) | At-least-once with acknowledgment | At-most-once, fire and forget |
| If no consumer is listening | Message waits | Message waits | Message is discarded |
| History / replay | None — popped is gone | Retained until trimmed | None |
| Fan-out to several consumers | No — one consumer per message | Yes, via consumer groups | Yes, to all subscribers |
| Tracks in-flight work | No | Yes — the pending entries list | No |
| Memory | Frees on pop | Grows until you trim it | None beyond client output buffers |
| Best for | Simple work queues, one worker pool | Event logs, replay, multiple independent consumer groups | Live notifications where staleness is worse than loss |
The short version: use pub/sub only when a message that arrives late is worse than a message that never arrives. It has no persistence and no acknowledgment — a subscriber that is disconnected for two seconds has permanently missed whatever was published in those two seconds. It is the wrong tool for a job queue, and it is chosen for one surprisingly often because "publish/subscribe" sounds like messaging.
Use a list when one pool of interchangeable workers processes each task once. Use a stream when you need several independent consumer groups reading the same events, or when you need to see what is currently in flight. A message queue built on streams gets acknowledgment tracking for free; on lists you build it yourself, which is the subject of the next section.
The Lost-Message Problem
Here is the failure that catches every list-based queue eventually.
A worker calls RPOP. Redis removes the element and returns it. The worker starts processing — and then the process is killed, the pod is evicted, or the node loses power. The message is not in Redis and not finished. It is gone, and nothing in the system knows it existed.
The standard mitigation is to never remove a message without simultaneously recording it somewhere else. LMOVE does this atomically:
> LMOVE tasks tasks:processing RIGHT LEFT
"{\"job\":\"resize\",\"id\":41}"
The message moves from the queue to a processing list in one operation, so there is no window where it exists nowhere. When the worker finishes, it removes the entry with LREM. If the worker dies, the entry stays in tasks:processing where something can find it.
That last clause is where hand-rolled implementations go wrong. The pattern only works if something actually sweeps the processing list — and writing that reaper is harder than it looks:
- You need a timestamp per message to know what is stale, which means the processing list holds wrapped entries rather than raw payloads.
- You need to distinguish slow from dead. Requeue too eagerly and a long job runs twice; too late and failures sit undetected for an hour.
- You need a retry ceiling. Without one, a message that crashes its consumer will crash every consumer, forever — a poison pill that takes down the pool. That ceiling is what a dead letter queue exists to catch.
- The reaper itself needs to be single-writer, or two reapers requeue the same message twice.
None of this is impossible. It is simply a real piece of distributed-systems engineering that teams tend to discover after the first incident rather than before it, and it is the reason the section on Reliable Queue below exists.
Note that RPOPLPUSH and BRPOPLPUSH, which you will still see in older tutorials, have been deprecated since Redis 6.2 in favour of LMOVE and BLMOVE. They still work; new code should use the newer commands, which can move in either direction.
Delayed and Scheduled Jobs
Lists have no concept of time. A task pushed now is available now. For "send this reminder in four hours" or "retry this payment with backoff," you need a different structure.
The standard pattern uses a sorted set with the scheduled timestamp as the score:
> ZADD delayed 1786512000 "{\"job\":\"reminder\",\"id\":9}"
(integer) 1
# a poller, once a second: what is due now?
> ZRANGE delayed -inf 1786512000 BYSCORE LIMIT 0 100
Use ZRANGE ... BYSCORE rather than ZRANGEBYSCORE, which has been deprecated since Redis 6.2 for the same reason RPOPLPUSH was.
A small scheduler process polls for due entries, moves them onto the live work queue, and removes them from the sorted set. The read and the removal must be atomic — two schedulers that range then ZREM as separate commands will both see the same entry and enqueue it twice. Use a Lua script, a lock, or WATCH/MULTI. ZPOPMIN is not a solution here: it pops the lowest-scored member unconditionally, with no score predicate, so a scheduler built on it fires jobs before they are due.
A tempting alternative is to set a TTL on a key and act on the expiry event via keyspace notifications. Do not build scheduling on this. Keyspace notifications are fire-and-forget pub/sub with no acknowledgment: if no subscriber is connected at the moment the key expires, the event is gone and the job silently never runs. Redis also deletes expired keys lazily, so the notification can fire noticeably after the deadline.
Priority Queues
A list is strictly FIFO. When some work must jump ahead — a paying customer's export before a free-tier one, a password reset before a marketing email — there are three approaches, in increasing order of fidelity.
Multiple lists. Keep tasks:high and tasks:low and have consumers check high first. BLPOP accepts several keys and returns from the first non-empty one in the order given, so this is a single blocking call rather than a poll loop. Cheap — the cost is linear in the number of keys you pass, which is two — but coarse, and a saturated high queue starves the low one indefinitely.
A sorted set keyed by priority. Score by priority and pop with ZPOPMIN, or BZPOPMIN if you want to keep the blocking read. Mind the direction: ZPOPMIN takes the lowest score, so this construction means score 0 is the most urgent job — the inverse of the 0-to-9 convention Reliable Queue uses further down, where 9 is highest. Pick one convention and hold it, or use ZPOPMAX/BZPOPMAX if you would rather have the larger number win. Nothing in Redis will catch a queue that quietly runs backwards.
Combine the priority with a timestamp in the score if you want ordering to stay stable within a level. Arbitrary priority levels, at O(log N) rather than the list's O(1).
Priority as a message attribute. The queue orders messages itself, without you maintaining parallel keys. This is what Redisson's RPriorityQueue and Reliable Queue's priority levels provide, covered below. For a fuller treatment of the trade-offs, see distributed priority queues in Java.
Job and Task Queues: Sidekiq, Celery, Resque and BullMQ
Most of what people call "a Redis job queue" is really one of a handful of libraries that use Redis as their storage layer. Knowing what each one actually does with Redis is useful, because it tells you exactly what you are replacing when a Java service needs the same capability.
| Library | Language | What it uses Redis for |
|---|---|---|
| Resque | Ruby | Lists, one per queue name; workers poll. The original, and the design most others borrowed |
| Sidekiq | Ruby | Lists with BRPOP, plus sorted sets for scheduled and retried jobs |
| Celery | Python | Lists as the broker transport, with a separate result backend |
| RQ | Python | Lists plus a registry of in-flight jobs |
| BullMQ | Node.js | Lists, sorted sets and hashes coordinated by Lua scripts for atomic state transitions |
The pattern is consistent: a list for ready work, a sorted set for anything scheduled, and application code for retries, acknowledgment and dead-lettering. None of that reliability logic lives in Redis. Every one of these libraries wrote it themselves, which is a useful thing to know before writing it a sixth time.
Java has no equivalent with the same mindshare — there is no Java Sidekiq. What it has instead is Redisson, which exposes the same primitives through java.util interfaces, and Reliable Queue, which implements the acknowledgment and retry layer those libraries each hand-rolled.
Redis Queues in Java with Redisson
Redisson maps Redis structures onto the Java collection interfaces a developer already knows, so a distributed queue is used the same way a local one is. Everything here works identically against Valkey.
RQueue implements java.util.Queue:
RQueue<SomeObject> queue = redisson.getQueue("anyQueue");
queue.add(new SomeObject());
SomeObject head = queue.peek(); // look without removing
SomeObject next = queue.poll(); // remove and return
RBlockingQueue implements java.util.concurrent.BlockingQueue, so consumers wait for work instead of polling for it — this is BRPOP behind a familiar interface:
RBlockingQueue<SomeObject> queue = redisson.getBlockingQueue("anyQueue");
queue.offer(new SomeObject());
SomeObject obj = queue.take(); // blocks until available
SomeObject timed = queue.poll(10, TimeUnit.MINUTES); // blocks with a bound
The poll, pollFromAny, pollLastAndOfferFirstTo and take methods automatically resubscribe after a reconnection or failover, so a consumer blocked at the moment a primary is replaced resumes rather than hanging.
For the patterns described earlier, Redisson provides direct equivalents:
| Object | Use it for |
|---|---|
RQueue / RDeque | Plain FIFO or double-ended queues |
RBlockingQueue / RBlockingDeque | Consumers that wait for work |
RPriorityQueue / RPriorityBlockingQueue | Ordering by a Comparator rather than arrival time |
RReliableQueue | Acknowledgments, retries, dead-lettering, delays, size limits (Redisson PRO) |
RDelayedQueue (deprecated) | Delayed delivery — superseded by RReliableQueue with delay() |
RBoundedBlockingQueue (deprecated) | Backpressure — superseded by RReliableQueue with a queue size limit |
Note the last two rows. RDelayedQueue and RBoundedBlockingQueue are deprecated; both capabilities now live on RReliableQueue as per-message delay() and a configurable maxSize, covered in the next section. Existing code using them still works, but new code should not start there.
A priority queue takes a comparator once, at creation:
RPriorityQueue<Job> queue = redisson.getPriorityQueue("jobs");
queue.trySetComparator(Comparator.comparingInt(Job::getPriority));
queue.add(new Job("export", 1));
Every object above also has asynchronous, Reactive Streams and RxJava3 variants. Full details are in the Redisson queue documentation.
Listening for Queue Events
Redisson can attach listeners to a queue object, which is useful for metrics and for reacting to a queue being deleted or expiring out from under a consumer:
| Listener class | Event |
|---|---|
org.redisson.api.listener.TrackingListener | Element created, removed or updated after a read |
org.redisson.api.listener.ListAddListener | Element created |
org.redisson.api.listener.ListRemoveListener | Element removed |
org.redisson.api.ExpiredObjectListener | Queue object expired |
org.redisson.api.DeletedObjectListener | Queue object deleted |
RQueue<SomeObject> queue = redisson.getQueue("anyQueue");
int listenerId = queue.addListener(new DeletedObjectListener() {
@Override
public void onDeleted(String name) {
// ...
}
});
// ...
queue.removeListener(listenerId);
Reliable Queue: Acknowledgments, Retries and Dead Letters
Everything above is built on the Redis list, which means it inherits the list's limit: once an element is popped, it is gone. The processing-list pattern narrows that window but leaves you owning the reaper, the retry ceiling and the poison-pill handling.
Reliable Queue, available in Redisson PRO, implements that layer. A message is removed only when a consumer acknowledges it, so a worker that dies mid-task triggers redelivery rather than silent loss:
RReliableQueue<Order> queue = redisson.getReliableQueue("order-queue");
Message<Order> msg = queue.add(QueueAddArgs.messages(
MessageArgs.payload(order)
.deliveryLimit(10) // then dead-letter, or drop if no DLQ is set
.timeToLive(Duration.ofDays(7))
.delay(Duration.ofMinutes(5)) // scheduled delivery
.priority(7) // levels 0-9
.header("region", "US-EAST")
.deduplicationById("ORD-123", Duration.ofHours(1))
));
The pieces that matter, and what each one replaces:
| Feature | Replaces |
|---|---|
| Manual and automatic acknowledgment | The processing-list pattern and its reaper |
| Negative acknowledgment (NACK) | Application-level retry bookkeeping |
| Visibility timeout, per queue or per poll | Timestamp-wrapping entries to tell slow from dead |
| Delivery limit and dead letter queue | Hand-written poison-pill detection |
| Deduplication by ID or payload hash | Producer-side idempotency keys |
| Priority levels 0–9 | Parallel high/low queues |
delay() per message | The sorted-set scheduler and its poller |
| Configurable queue and message size limits | Unbounded growth under producer pressure |
Be precise about what each of these buys you, because "exactly-once" is the most oversold phrase in messaging. Deduplication is an add-time filter: within the window you configure, a message with an ID or payload hash already seen is not accepted onto the queue. That stops a retrying producer from enqueuing the same job twice. It does not suppress redelivery of a message the queue already accepted and handed out again after a visibility timeout or a NACK.
So the honest summary is: acknowledgment plus visibility timeouts give you at-least-once delivery with automatic recovery from dead consumers; deduplication removes the duplicate-enqueue case; the delivery limit stops a poison pill — though set deadLetterQueueName as well, because a message that exhausts its delivery limit with no dead letter queue configured is simply deleted; and your handler still needs to be idempotent for the redelivery case. Exactly-once delivery is not achievable end-to-end over an unreliable network without idempotency at the consumer — what production systems actually need is for each message to take effect exactly once, and these features are the machinery that gets you there with far less application code.
Capacity is a queue-level setting, and a producer that should wait for space rather than fail passes a timeout:
queue.setConfig(QueueConfig.defaults().maxSize(100));
// waits while the queue is full, returns null once the timeout expires
Message msg = queue.add(QueueAddArgs
.messages(MessageArgs.payload(new SomeObject()))
.timeout(Duration.ofSeconds(30)));
For the full feature breakdown see Reliable Queue for Valkey and Redis, or the comparison against Amazon SQS.
Running Queues in a Cluster
A Redis list is a single key, and a single key lives on a single slot, which lives on a single node. A queue does not shard. Adding nodes to a Redis Cluster gives you more capacity overall but not more throughput on one queue — every push and pop for tasks lands on the same node, and at high enough volume that node becomes a hot slot while the rest of the cluster idles.
If one queue is genuinely the bottleneck, partition it explicitly — tasks:0 through tasks:7, with producers hashing onto a partition and each consumer group reading a subset. You give up global FIFO ordering, which is usually acceptable, and you should confirm that before doing it.
Two related constraints. Commands that touch several keys at once, such as BLPOP across multiple queues or LMOVE between a queue and its processing list, require all keys on the same slot — use a hash tag like {tasks}:pending and {tasks}:processing to force that. And a blocking consumer holds a connection to one specific node, so consumer count and cluster connection limits need sizing together.
Frequently Asked Questions
Can Redis Be Used as a Message Queue?
Yes, and it is a common production choice. Redis has no queue data type, so you build one from lists, sorted sets or streams and add whatever delivery guarantees you need. It suits low-latency work queues well; it is a weaker fit when you need long retention, replay across many consumer groups, or routing rules, which are the things dedicated brokers are built for.
What Is the Difference Between a Redis Queue and a Message Queue?
A message queue is the pattern; a Redis queue is one implementation of it. Dedicated brokers such as RabbitMQ or Amazon SQS ship acknowledgment, redelivery, dead-lettering and routing as built-in behaviour. On Redis, a list gives you ordering and atomic pops, and the rest is yours to build — or to get from a library that has built it.
Is Redis a Good Queue for Production?
For high-throughput, low-latency work queues where occasional redelivery is acceptable, yes — Sidekiq, Celery and BullMQ all run on it at scale. The caveats are that a list-based queue loses in-flight messages when a consumer crashes unless you add acknowledgment, and that a queue is a single key and therefore does not shard across a cluster.
What Happens to a Redis Queue Message if the Worker Crashes?
With a plain RPOP, it is lost — the message left Redis and never finished. Moving it to a processing list with LMOVE keeps a copy, but only helps if something sweeps that list and requeues stale entries. Streams track in-flight messages in a pending entries list, and Reliable Queue redelivers automatically once the visibility timeout expires.
How Do You Delay a Job in Redis?
Store it in a sorted set scored by its due timestamp and have a scheduler move due entries onto the work queue with ZRANGE ... BYSCORE, making the read-and-remove atomic with a Lua script or a lock so two schedulers cannot enqueue the same job twice. In Java, Reliable Queue's per-message delay() does this for you. Do not build scheduling on key expiry and keyspace notifications — those events are fire-and-forget and are lost if no subscriber is connected.
Redis Queue or Kafka?
Different shapes of problem. Redis is optimised for low-latency task dispatch where messages are consumed once and discarded. Kafka is a durable, partitioned log built for retention, replay and many independent consumer groups reading the same stream. If you need to reprocess last week's events, you want a log; if you need a job picked up in under a millisecond, you want Redis. Redis Streams sit between the two and are often enough.
Is Sidekiq Just a Redis Queue?
Sidekiq stores jobs in Redis lists and uses sorted sets for scheduled and retried work, so the storage layer is exactly what is described above. What it adds is everything around it: a worker pool, retry with backoff, a dead set, scheduling and a web UI. The same is true of Celery, Resque, RQ and BullMQ — the Redis part is the small part.