Distributed Priority Queues in Java with Valkey and Redis
A java.util.PriorityQueue works beautifully right up to the moment you run a second instance of your application. Then you have two queues, each holding half the work, each convinced it knows what to process next. Neither is wrong, and neither is useful.
The fix is to move the queue out of heap memory and into a store every instance can reach. This guide covers three ways to do that on Valkey or Redis, what each one costs you, and how to pick. Every example works identically against both, and assumes you already have a RedissonClient named redisson. If you don't, How to Connect to Redis in Java covers that in a few minutes.
Why a Redis priority queue has to be built
Neither Valkey nor Redis ships a priority queue as a native type. The list type preserves insertion order and nothing else, so LPUSH and RPOP give you FIFO and no way to say "this one first." A Redis priority queue therefore has to be assembled out of other primitives, which is why almost every answer you'll find online starts with sorted sets.
Sorted sets are the right primitive. Each member carries a numeric score, and the set stays permanently ordered by it. Treat the score as the priority and you have the beginnings of a priority queue.
Approach 1: sorted sets
At the protocol level, a priority queue on a sorted set is two commands. Add work with a score, then pop the lowest-scoring member:
ZADD tasks 1 "charge-card"
ZADD tasks 5 "send-receipt"
ZADD tasks 9 "update-analytics"
ZPOPMIN tasks # "charge-card", score 1
In Java, Redisson exposes the same structure as RScoredSortedSet:
RScoredSortedSet<String> tasks = redisson.getScoredSortedSet("tasks");
tasks.add(1, "charge-card");
tasks.add(5, "send-receipt");
tasks.add(9, "update-analytics");
String next = tasks.pollFirst(); // charge-card
This works, it is atomic, and for a lot of internal tooling it is genuinely enough. Be clear about what it isn't, though.
A sorted set holds strings, so anything richer than an ID means serializing objects yourself and deciding where the real payload lives. Scores are doubles, so ordering logic that would naturally be a Comparator has to be flattened into a single number. There is no blocking consumer, so workers either poll on a timer or you bolt on a notification channel.
The one that actually hurts in production is this: ZPOPMIN removes the member. If the worker that popped it dies while processing, that work is gone. There is no acknowledgment, no redelivery, no record that anything was lost. Every article that stops at sorted sets stops one step before the problem that will page you at 3am.
Approach 2: RPriorityQueue
If what you want is java.util.PriorityQueue semantics with the data held centrally, Redisson's RPriorityQueue implements java.util.Queue and stores its contents in Valkey or Redis. Ordering comes from Comparable, exactly as it would in plain Java:
public class Task implements Comparable<Task>, Serializable {
private String name;
private int priority;
public Task(String name, int priority) {
this.name = name;
this.priority = priority;
}
// a lower number means a higher priority
@Override
public int compareTo(Task other) {
return Integer.compare(this.priority, other.priority);
}
public String getName() {
return name;
}
}
RPriorityQueue<Task> queue = redisson.getPriorityQueue("tasks");
queue.add(new Task("send-receipt", 5));
queue.add(new Task("charge-card", 1));
queue.add(new Task("update-analytics", 9));
Task first = queue.poll(); // charge-card
Task second = queue.poll(); // send-receipt
You keep real objects, real serialization, and real ordering logic. Where the ordering doesn't belong on the class itself, trySetComparator() sets a Comparator instead, and it is stored alongside the queue so that every client orders elements the same way.
Four variants exist, each mapping to a standard Java interface:
| Redisson object | Java interface | Use when |
|---|---|---|
RPriorityQueue | Queue | Basic priority ordering is all you need |
RPriorityDeque | Deque | You need access at both ends |
RPriorityBlockingQueue | BlockingQueue | Consumers should block until work arrives |
RPriorityBlockingDeque | BlockingDeque | Blocking consumers, both ends |
The blocking variant is what most worker loops actually want, since it removes the polling timer entirely:
RPriorityBlockingQueue<Task> queue = redisson.getPriorityBlockingQueue("tasks");
while (running) {
try {
Task task = queue.take(); // blocks until work is available
process(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
All four are part of the free, open-source Redisson edition. And all four share the sorted set's central weakness: poll() and take() remove the element. If process(task) throws, or the pod is evicted mid-call, the task is gone. Better ergonomics, same delivery guarantee.
Approach 3: priority with delivery guarantees
Most teams that reach for a distributed priority queue are really building a job processor, and a job processor needs more than ordering. It needs to know a message was handled, to retry when it wasn't, and to give up somewhere visible after too many failures.
That is what the Reliable Queue in Redisson PRO provides. Priority is one setting among several rather than the entire mechanism. Configure the queue once:
RReliableQueue<Job> queue = redisson.getReliableQueue("tasks");
queue.setConfig(QueueConfig.defaults()
.deliveryLimit(4)
.visibilityTimeout(Duration.ofSeconds(60))
.deadLetterQueueName("tasks-dlq"));
Then attach a priority when adding work. Note that the payload is now a plain object: priority has moved off the class and onto the message, so there is no Comparable to implement. Priority is an integer from 0 to 9, where 0 is lowest and 9 is highest, defaulting to 0:
Message<Job> msg = queue.add(QueueAddArgs.messages(
MessageArgs.payload(new Job("charge-card"))
.priority(9)
.deliveryLimit(3)
.header("tenant", "acme")));
String id = msg.getId();
Note the direction is the opposite of a Comparator: here a higher number means more urgent. It is worth writing that down somewhere your team will see it, because the two conventions sitting side by side in one codebase is a reliable source of confusion.
Consumption is where the guarantees appear. A polled message becomes invisible to other consumers for the visibility timeout rather than being deleted:
Message<Job> message = queue.poll(QueuePollArgs.defaults()
.acknowledgeMode(AcknowledgeMode.MANUAL)
.timeout(Duration.ofSeconds(30)));
if (message != null) {
try {
process(message.getPayload());
queue.acknowledge(QueueAckArgs.ids(message.getId()));
} catch (TransientException e) { // your own exception types
queue.negativeAcknowledge(QueueNegativeAckArgs
.failed(message.getId())
.delay(Duration.ofSeconds(30)));
} catch (InvalidTaskException e) {
queue.negativeAcknowledge(QueueNegativeAckArgs
.rejected(message.getId()));
}
}
Three outcomes, three behaviours. Acknowledge and the message is deleted. Negatively acknowledge as failed and it is redelivered after your chosen delay, until the delivery limit is reached. Negatively acknowledge as rejected and it goes straight to the dead-letter queue. Crash without doing any of them and the visibility timeout expires and the message comes back on its own.
That last case is the one the earlier approaches cannot express at all.
Delayed queues and scheduled work
A Redis delayed queue and a priority queue usually turn out to be the same requirement: process this urgently, process that in five minutes, retry the other thing with backoff. Delay is a message-level setting:
queue.add(QueueAddArgs.messages(
MessageArgs.payload(new Job("send-reminder"))
.priority(2)
.delay(Duration.ofMinutes(15))));
The message is invisible until the delay elapses, then enters the queue and competes on priority as normal.
One thing to watch if you are following older tutorials: RDelayedQueue is now deprecated and superseded by the Reliable Queue's delay feature. RBoundedBlockingQueue is deprecated too, superseded by the queue size limit. A good deal of the material written about delayed queues in Redis predates both changes, so check the object you're copying against the current documentation before building on it.
Starvation: the failure mode that arrives late
Priority queues have a failure mode that only shows up under sustained load, long after the code ships. If high-priority work arrives faster than your workers drain it, low-priority work is never processed. Not delayed. Never. The queue is behaving exactly as designed, and a customer is waiting on a job that will not run.
Nothing in a priority queue solves this for you, because the fix is a policy decision. The two practical patterns:
- Aging. Periodically re-add long-waiting messages at a higher priority so they eventually surface. Costs you a sweeper job and some bookkeeping.
- Separate queues with reserved capacity. Run one queue per tier and dedicate workers to the lower tiers, so low-priority work always has somewhere to run. Simpler to reason about, and usually the better answer.
Whichever you choose, decide it deliberately. Reaching for a priority queue without deciding is how the third-priority tier quietly stops working two months after launch.
Choosing an approach
| What you need | Use |
|---|---|
| Simple score-based ordering, IDs only, losing an item is survivable | RScoredSortedSet |
Real Java objects and Comparator ordering, shared across instances | RPriorityQueue |
| The above, with workers that block rather than poll | RPriorityBlockingQueue |
| At-least-once delivery, retries, dead-letter handling, delay | RReliableQueue |
A reasonable rule: if losing a queued item would mean a customer never gets something they paid for, you need acknowledgments, and the first three options are the wrong tool no matter how convenient their APIs are.
Frequently asked questions
Does Redis support priority queues natively?
No. Redis and Valkey have no priority queue type. Lists are insertion-ordered only. Priority is normally built on sorted sets, using the score as the priority, or provided by a client library such as Redisson.
How do I implement a delayed queue in Valkey or Redis?
Set a delay on the message when adding it to a Reliable Queue, and it stays invisible until the delay elapses. The older RDelayedQueue object is deprecated in favour of this approach.
What is the difference between a priority queue and a sorted set?
A sorted set is a data structure that keeps members ordered by a numeric score. A priority queue is a behaviour: removal always returns the most important element. Sorted sets are one way to implement that behaviour, but they provide no acknowledgment or redelivery of their own.
Is RPriorityQueue available in the free version of Redisson?
Yes. RPriorityQueue, RPriorityDeque, RPriorityBlockingQueue and RPriorityBlockingDeque are all in the open-source edition. The Reliable Queue, with acknowledgments and dead-letter queues, is a Redisson PRO feature.
Do these approaches work with Valkey?
Yes. Valkey is wire-compatible with Redis, and every example here runs unchanged against either.
Next steps
If you are choosing a structure rather than a queue specifically, Redis Data Structures in Java maps every core type to its Java equivalent. For background on the underlying concept, see what a Java priority queue is. Full configuration for every queue object is in the Redisson queues documentation.
The Reliable Queue, along with local caching, data partitioning and advanced eviction, is part of Redisson PRO. You can try it for free.