Scope it before the first class
Say what you are building in one breath: schedule a task at a time, after a delay, or repeatedly at a fixed rate or with a fixed delay; cancel it; run due tasks on a bounded pool. Then say what is out: persistence across restarts, cron syntax, multiple machines. A job scheduler that survives restarts or spreads work across nodes needs leases and a durable store, and that is a system-design question rather than a ninety-minute class: running every job once when workers die covers it.
Mention that ScheduledThreadPoolExecutor already does most of this in the JDK. The interviewer wants to see you build the mechanism, but knowing the library exists, and where you would diverge from it, is part of the answer.
The core model
Three types carry the design. A Job is the identity: an id, the task, its schedule and a generation number. A Schedule answers one question, "given when this run was due and when it finished, when is the next one?" A ScheduledRun is one queued occurrence, and it is what the DelayQueue sorts.
interface Schedule {
OptionalLong next(long plannedNanos, long finishedNanos);
}
record Job(String id, Runnable task, Schedule schedule, long generation) {}
final class ScheduledRun implements Delayed {
private static final AtomicLong SEQ = new AtomicLong();
final String jobId;
final long generation;
final long runAtNanos;
private final long seq = SEQ.getAndIncrement();
ScheduledRun(String jobId, long generation, long runAtNanos) {
this.jobId = jobId;
this.generation = generation;
this.runAtNanos = runAtNanos;
}
@Override public long getDelay(TimeUnit unit) {
return unit.convert(runAtNanos - System.nanoTime(), TimeUnit.NANOSECONDS);
}
@Override public int {
(ScheduledRun) other;
runAtNanos - o.runAtNanos;
(diff != ) diff < ? - : ;
Long.compare(seq, o.seq);
}
}
Two details in that class are worth narrating. System.nanoTime() is monotonic, so a wall-clock correction does not make a delay run early or hang. Its Javadoc also says to compare values by subtraction because the counter can overflow, which is why compareTo subtracts. The sequence number gives a stable order to two jobs due at the same nanosecond, which makes tests deterministic.
Why a DelayQueue and one dispatcher
The naive design is a timer that wakes every second and scans every job. It costs O(n) per tick, and its precision is the tick length. A priority queue ordered by run time means the dispatcher only ever looks at the head.
The hand-rolled version of that has a classic bug. You peek at the head, see it is due at 10:05, and sleep until 10:05. A job due at 10:01 arrives at 10:00 and waits four minutes behind your sleep. DelayQueue.take() avoids this because inserting a new earliest element wakes the waiting taker. If you build it yourself on a PriorityQueue, you need a lock and a condition that schedule signals.
private void dispatchLoop() {
while (!Thread.currentThread().isInterrupted()) {
ScheduledRun run;
try {
run = queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
jobs.get(run.jobId);
(job == || job.generation() != run.generation) ;
workers.execute(() -> runAndReschedule(job, run));
}
}
The dispatcher never runs a task itself. If it did, a job that takes thirty seconds would hold back every job behind it. Workers are a fixed pool, so the cost of overload is visible lateness rather than unbounded threads.
Cancellation without searching the queue
DelayQueue.remove(Object) is a linear scan over the heap. The cheaper move is lazy cancellation: cancel(id) removes the job from a ConcurrentHashMap, and the dispatcher drops any entry whose job is gone or whose generation no longer matches. The JDK makes the same trade by default: a cancelled ScheduledThreadPoolExecutor task stays in the queue until its delay elapses, unless you turn on its remove-on-cancel policy.
The generation number matters when a job is rescheduled under the same id. The old queued entry still exists, but its generation is stale, so it dies quietly on dequeue. Say what cancellation does not do: a run already executing finishes, because interrupting arbitrary user code is unsafe. Cancellation only prevents future runs.
Recurring jobs and missed runs
Rescheduling happens after the run completes, so a recurring job never overlaps itself. Note the cancel check before the re-queue. A cancel that lands between that check and put leaves one stale entry, and the dispatcher's generation check drops it.
private void runAndReschedule(Job job, ScheduledRun run) {
try {
job.task().run();
} catch (RuntimeException e) {
failures.record(job.id(), e);
}
long finished = System.nanoTime();
job.schedule().next(run.runAtNanos, finished).ifPresent(next -> {
if (jobs.get(job.id()) == job) queue.put(new ScheduledRun(job.id(), job.generation(), next));
});
}
Fixed delay computes the next time from finished: the gap between runs is constant and the start times drift. Fixed rate computes it from plannedNanos: the start times stay on a grid, and a slow run eats into the gap. Both are one-line Schedule implementations, and a candidate who writes only one is usually asked for the other next.
A fixed-rate job that overruns its period, or a process paused by a long garbage-collection stop, leaves the next grid slot in the past. You have three policies. Catch-up runs every missed slot back to back. Coalesce runs once immediately to cover them all, then rejoins the grid. Skip abandons the missed slots and waits for the next future one. Pick by what the job does: a metrics flush wants coalesce, a "send the 09:00 digest" job wants skip, and almost nothing wants a burst of catch-up. Skip is the simplest to write:
long behind = finishedNanos - plannedNanos;
return OptionalLong.of(plannedNanos + (behind / periodNanos + 1) * periodNanos);
Trace it once. Due at 0 with a period of 10, a run finishing at 5 gives 10, the normal case. A run finishing at 35 gives 40, not 10, 20 and 30 in a burst. For comparison, the JDK's scheduleAtFixedRate lets later runs start late but never concurrently.
The silent death of a recurring job
The detail that separates answers here is exceptions. ScheduledExecutorService documents that if any run of a fixed-rate or fixed-delay task throws, subsequent runs are suppressed. Nothing is logged unless you inspect the returned future. A nightly clean-up job throws once on a bad row and simply stops, and nobody notices for a week.
Your job scheduler should catch the exception, record it against the job, and keep the schedule, or apply a stated limit such as pausing a job after five consecutive failures. Either is defensible. Letting the reschedule sit after task.run() with no try is not, because it reproduces the JDK behaviour by accident.
What remains and how you test it
Wall-clock schedules such as "every day at 02:00" need a Clock, because nanoTime has no notion of calendar time or daylight saving. Compute the next instant from the injected clock, then convert to a delay. For tests, the reliable approach is to keep the ordering and dispatch logic in a class that accepts "now" as a parameter, with a runDue(now) method. Ordering, cancellation and missed-run policy can then be tested without sleeping, and the DelayQueue wiring stays a thin shell.
Shutdown is the last thing to say: interrupt the dispatcher, stop accepting schedules, and let the worker pool drain or cancel by a stated policy.
Sort by next run time, keep waiting and working on different threads, and decide in advance what a slow run, a cancelled run and a failed run each do to the next one.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).