A DelayQueue is an unbounded BlockingQueue from java.util.concurrent that holds Delayed elements and lets a consumer take an element only after that element’s delay has expired. Elements leave the queue in the order of their expiry time, not in the order in which they were added.
We use a DelayQueue for work that must happen after a wait, such as cancelling an unpaid order after 15 minutes, retrying a failed call with a backoff, expiring cache entries or sending reminders. One consumer thread blocks in take() and wakes up when the next element is due.
The following example adds two reminders with different delays and shows which methods return what before and after the first delay expires.
DelayQueue<Reminder> queue = new DelayQueue<>();
queue.put(Reminder.in("call mom", 300));
queue.put(Reminder.in("stretch", 50));
Reminder early = queue.poll(); // null, nothing has expired yet
String next = queue.peek().text(); // "stretch", the next to expire
String due = queue.take().text(); // "stretch", after about 50 ms
int left = queue.size(); // 1
Notice that poll() returned null although the queue held two elements, while peek() already showed the next one. We build the Reminder class, go through the rules for the head of the queue, write a producer and consumer, cancel a pending element, and compare DelayQueue with ScheduledExecutorService.
1. Implementing the Delayed Interface
Every element of a DelayQueue implements Delayed, which adds one method to Comparable<Delayed>. The getDelay(TimeUnit) method returns the time left until the element is due, and a value of zero or less means the element has expired. The compareTo() method must order elements consistently with getDelay(), so the queue can keep the next element to expire at the front.
The simplest correct implementation stores the moment the element becomes due as a System.nanoTime() value. The following record does that and adds a factory method that takes the delay in milliseconds.
record Reminder(String text, long dueAt) implements Delayed {
static Reminder in(String text, long delayMs) {
return new Reminder(text, System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delayMs));
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(dueAt - System.nanoTime(), TimeUnit.NANOSECONDS);
}
@Override
public int compareTo(Delayed other) {
return Long.compare(getDelay(TimeUnit.NANOSECONDS), other.getDelay(TimeUnit.NANOSECONDS));
}
}
Use System.nanoTime() for delays, not LocalDateTime.now() or System.currentTimeMillis(). The wall clock jumps when the system time is corrected or when daylight saving time starts, which makes elements expire too early or hours too late. The nanoTime() clock only moves forward, which is all a delay needs.
Reminder soon = Reminder.in("tea", 1000);
boolean waiting = soon.getDelay(TimeUnit.MILLISECONDS) > 0; // true
Reminder past = Reminder.in("late", -10);
boolean expired = past.getDelay(TimeUnit.NANOSECONDS) <= 0; // true
A negative delay is allowed and makes the element available at once, which is handy for retrying a task right away.
2. How DelayQueue Releases Elements
A DelayQueue distinguishes two things at the front of the queue. The head is the element with the earliest expiry time, whether that time is in the past or in the future. The expired head is the same element, but only once its delay has run out.

DelayQueue implements BlockingQueue but breaks its general contract on purpose, because the removal methods ignore elements that have not expired. The other methods treat all elements alike.
| Method | Pending elements only | At least one expired element |
|---|---|---|
| take() | Blocks until the head expires | Removes and returns the expired head |
| poll() | Returns null | Removes and returns the expired head |
| poll(timeout, unit) | Returns null if nothing expires within the timeout | Removes and returns the expired head |
| remove() | Throws NoSuchElementException | Removes and returns the expired head |
| peek() | Returns the head without removing it | Returns the expired head without removing it |
| size() | Counts all elements | Counts all elements |
| drainTo(collection) | Moves nothing | Moves every expired element |
Two more rules complete the picture. The queue is unbounded, so put() and offer() never block, and it rejects null elements because a null has no delay.
DelayQueue<Reminder> rules = new DelayQueue<>();
rules.put(Reminder.in("later", 5000));
int size = rules.size(); // 1
Reminder none = rules.poll(); // null
Reminder removed = rules.remove(); // NoSuchElementException
boolean added = rules.offer(null); // NullPointerException
3. DelayQueue Producer and Consumer Example
An online shop gives customers 15 minutes to pay for an order, and after that the order is cancelled so the reserved stock goes back on sale. A DelayQueue fits that rule well. The checkout code puts a pending order in the queue, and one consumer thread takes orders as they expire. The example uses milliseconds instead of minutes so it runs in a fraction of a second.
record PendingOrder(String id, long dueAt) implements Delayed {
static PendingOrder expiresIn(String id, long delayMs) {
return new PendingOrder(id, System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delayMs));
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(dueAt - System.nanoTime(), TimeUnit.NANOSECONDS);
}
@Override
public int compareTo(Delayed other) {
return Long.compare(getDelay(TimeUnit.NANOSECONDS), other.getDelay(TimeUnit.NANOSECONDS));
}
}
The consumer is a loop around take(). It stops when the thread is interrupted, which is the normal way to stop a blocked consumer, as described in the InterruptedException guide.
static Runnable canceller(DelayQueue<PendingOrder> pending, List<String> cancelled, CountDownLatch progress) {
return () -> {
try {
while (true) {
PendingOrder order = pending.take(); // blocks until an order expires
cancelled.add(order.id());
progress.countDown();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // shutdown requested
}
};
}
The producer adds three orders in the order 1, 2, 3, but with different payment windows.
DelayQueue<PendingOrder> pending = new DelayQueue<>();
List<String> cancelled = new CopyOnWriteArrayList<>();
CountDownLatch progress = new CountDownLatch(3);
Thread consumer = Thread.ofPlatform().name("order-canceller").start(canceller(pending, cancelled, progress));
pending.put(PendingOrder.expiresIn("order-1", 300));
pending.put(PendingOrder.expiresIn("order-2", 100));
pending.put(PendingOrder.expiresIn("order-3", 200));
progress.await();
consumer.interrupt();
consumer.join();
List<String> result = List.copyOf(cancelled); // [order-2, order-3, order-1]
We can see that the consumer cancelled the orders by expiry time, so order-2 came first even though it was added second. The consumer thread used no CPU while it waited, because take() parks the thread until the head is due.
When the work is cheaper in batches, a consumer can call drainTo() at a fixed interval instead of taking elements one by one. It moves every expired element and leaves the pending ones in the queue.
DelayQueue<PendingOrder> batch = new DelayQueue<>();
batch.put(PendingOrder.expiresIn("a", -5));
batch.put(PendingOrder.expiresIn("b", -1));
batch.put(PendingOrder.expiresIn("c", 5000));
List<PendingOrder> due = new ArrayList<>();
int moved = batch.drainTo(due); // 2
int stillPending = batch.size(); // 1
4. Removing an Element Before It Expires
In the shop example, most customers pay in time, and their orders must leave the queue without being cancelled. The remove(Object) method removes a specific element, expired or not. It finds the element with equals(), which a record generates from all its fields, so we keep a reference to the queued object or look it up by id.
DelayQueue<PendingOrder> open = new DelayQueue<>();
PendingOrder order = PendingOrder.expiresIn("order-7", 60_000);
open.put(order);
boolean paid = open.remove(order); // true, the customer paid
boolean byId = open.removeIf(o -> o.id().equals("order-8")); // false, not in the queue
int remaining = open.size(); // 0
Removing an element is a linear scan, so it costs more as the queue grows. For tens of thousands of pending items with frequent cancellation, a ScheduledExecutorService, where ScheduledFuture.cancel() does not scan the queue, or a database query on an expiry column, can be the better design.
5. DelayQueue vs ScheduledExecutorService vs PriorityBlockingQueue
A DelayQueue works like a PriorityBlockingQueue whose removal methods return only expired elements. Choosing between it and the alternatives comes down to whether we want to keep data or run code.
| Need | DelayQueue | ScheduledExecutorService | PriorityBlockingQueue |
|---|---|---|---|
| Holds | Data elements | Tasks to run | Data elements |
| Order | By expiry time | By scheduled time | By compareTo() or a Comparator |
| Element available | Only after its delay | Task runs at its time | At once |
| Who processes | Our own consumer thread | The executor’s threads | Our own consumer thread |
| Inspect or batch pending items | Yes, peek(), size(), drainTo() | No | Yes |
| Repeating schedules | No | Yes, scheduleAtFixedRate() | No |
If all we want is to run a piece of code later, ScheduledExecutorService needs less code. A DelayQueue is the better choice when the delayed items are data that other code inspects, cancels, counts or processes in batches, as in the classic producer-consumer setup. The Java concurrency guide lists the other blocking queues.
6. DelayQueue FAQs
Two symptoms raise most questions about this queue, a poll() that returns null on a non-empty queue and pending items that vanish after a restart.
6.1. Is DelayQueue thread-safe?
Yes. All methods use one internal ReentrantLock, so any number of producer and consumer threads can share a queue. When several consumers wait in take(), only one of them waits with a timer for the head, and the others wait until it is their turn.
6.2. Why does poll() return null when size() is greater than zero?
Because poll() returns only an expired element, whereas size() counts pending elements too. The peek() method shows which element expires next.
6.3. Is DelayQueue bounded?
No. It grows without limit, so put() never blocks and a fast producer can run the app out of memory. If a limit is needed, the producer checks size() or acquires a Semaphore permit before adding.
6.4. Does a DelayQueue survive an application restart?
No. It is an in-memory collection, so pending elements are lost when the JVM stops. For delays of minutes or hours that must survive a deployment, we store the due time in a database and use the queue only as a cache, or use a persistent scheduler.
6.5. In what order are elements with the same delay returned?
The order is not specified. The queue orders elements only by compareTo(), so for a stable order among equal delays we add a second criterion, such as a sequence number, to compareTo().
7. Conclusion
A DelayQueue holds Delayed elements and releases each one only after its delay expires, in expiry order. The take(), poll() and remove() methods see only expired elements, while peek(), size() and iteration see all of them.
A correct element stores its due time from System.nanoTime() and compares by remaining delay. With one consumer blocked in take(), the queue fits order timeouts, retries and expiring entries well, whereas plain delayed code is easier with ScheduledExecutorService.
8. References
- DelayQueue Javadoc (Java 25)
- Delayed Javadoc (Java 25)
- System.nanoTime() Javadoc (Java 25)
- BlockingQueue Javadoc (Java 25)
Happy Learning !!