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.

CommandWhat it doesComplexity
LPUSH / RPUSHAppend to the head or tailO(1) per element
LPOP / RPOPRemove and return from the head or tailO(1) without COUNT
BLPOP / BRPOPThe same, but block until an element arrives or the timeout expiresO(N) in the number of keys given
LMOVEAtomically pop from one list and push to anotherO(1)
BLMOVEBlocking LMOVEO(1)
LMPOP / BLMPOPPop multiple elements, or from the first non-empty of several lists (Redis 7.0+)O(N+M), keys and elements returned
LLENQueue depth — the metric to alert onO(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.

ListStreamPub/Sub
DeliveryAt-most-once (message leaves on pop)At-least-once with acknowledgmentAt-most-once, fire and forget
If no consumer is listeningMessage waitsMessage waitsMessage is discarded
History / replayNone — popped is goneRetained until trimmedNone
Fan-out to several consumersNo — one consumer per messageYes, via consumer groupsYes, to all subscribers
Tracks in-flight workNoYes — the pending entries listNo
MemoryFrees on popGrows until you trim itNone beyond client output buffers
Best forSimple work queues, one worker poolEvent logs, replay, multiple independent consumer groupsLive 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.

LibraryLanguageWhat it uses Redis for
ResqueRubyLists, one per queue name; workers poll. The original, and the design most others borrowed
SidekiqRubyLists with BRPOP, plus sorted sets for scheduled and retried jobs
CeleryPythonLists as the broker transport, with a separate result backend
RQPythonLists plus a registry of in-flight jobs
BullMQNode.jsLists, 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:

ObjectUse it for
RQueue / RDequePlain FIFO or double-ended queues
RBlockingQueue / RBlockingDequeConsumers that wait for work
RPriorityQueue / RPriorityBlockingQueueOrdering by a Comparator rather than arrival time
RReliableQueueAcknowledgments, 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 classEvent
org.redisson.api.listener.TrackingListenerElement created, removed or updated after a read
org.redisson.api.listener.ListAddListenerElement created
org.redisson.api.listener.ListRemoveListenerElement removed
org.redisson.api.ExpiredObjectListenerQueue object expired
org.redisson.api.DeletedObjectListenerQueue 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:

FeatureReplaces
Manual and automatic acknowledgmentThe processing-list pattern and its reaper
Negative acknowledgment (NACK)Application-level retry bookkeeping
Visibility timeout, per queue or per pollTimestamp-wrapping entries to tell slow from dead
Delivery limit and dead letter queueHand-written poison-pill detection
Deduplication by ID or payload hashProducer-side idempotency keys
Priority levels 0–9Parallel high/low queues
delay() per messageThe sorted-set scheduler and its poller
Configurable queue and message size limitsUnbounded 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.