Java ScheduledExecutorService: Fixed Rate vs Fixed Delay

Java ScheduledExecutorService examples for delayed and periodic tasks, scheduleAtFixedRate vs scheduleWithFixedDelay, exceptions, cancel and daily jobs.

Timeline comparing scheduleAtFixedRate and scheduleWithFixedDelay for a 3 second task with a 10 second period

A ScheduledExecutorService is an ExecutorService that runs a task once after a delay, or again and again at a fixed rate or with a fixed delay, and returns a ScheduledFuture that we use to read the result or cancel the task. The JDK implementation is ScheduledThreadPoolExecutor, which we get from Executors.newScheduledThreadPool() or Executors.newSingleThreadScheduledExecutor().

We use it for reminders that fire after a few minutes, cache refreshes, health checks and cleanup jobs that run every few seconds inside one JVM.

The following example schedules a one-time reminder after 100 milliseconds and a heartbeat every 20 milliseconds, and stops both with try-with-resources.

AtomicInteger pings = new AtomicInteger();
ScheduledFuture<String> reminder;
ScheduledFuture<?> heartbeat;

try (ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor()) {

    reminder = scheduler.schedule(() -> "sent", 100, TimeUnit.MILLISECONDS);
    heartbeat = scheduler.scheduleAtFixedRate(pings::incrementAndGet, 0, 20, TimeUnit.MILLISECONDS);
    reminder.get();
}

String result = reminder.resultNow();           // "sent"
boolean beating = pings.get() >= 3;             // true
boolean stopped = heartbeat.isCancelled();      // true

Notice that closing the scheduler cancelled the periodic heartbeat, whereas the delayed reminder had already run. We go through the four scheduling methods, the difference between fixed rate and fixed delay, what an exception does to a periodic task, cancelling and shutdown, running a job at a set time of day and the alternatives.

1. The Four Scheduling Methods

The ScheduledExecutorService interface adds four methods to ExecutorService. All of them take a delay relative to the current time, not a date, and a zero or negative delay means the task runs as soon as a thread is free. A period or delay of zero or less for the two periodic methods throws IllegalArgumentException.

MethodTask typeRunsReturns
schedule(Runnable, delay, unit)RunnableOnce, after the delayScheduledFuture<?>
schedule(Callable<V>, delay, unit)CallableOnce, after the delayScheduledFuture<V> with the result
scheduleAtFixedRate(Runnable, initialDelay, period, unit)RunnableEvery period, measured from start to startScheduledFuture<?>
scheduleWithFixedDelay(Runnable, initialDelay, delay, unit)RunnableWith delay between the end of one run and the start of the nextScheduledFuture<?>

A ScheduledFuture is a Future that also implements Delayed, so its getDelay() method tells how long until the next run. The periodic methods accept only a Runnable, because a task that runs forever has no single result.

2. Running a Task Once After a Delay

Say a shop sends a reminder email when a customer leaves items in the cart for 30 minutes. When the cart is checked out earlier, the reminder must not go out. A delayed task with a ScheduledFuture does both, because we can cancel the future at checkout.

ScheduledFuture<String> cartReminder;
ScheduledFuture<?> discarded;

try (ScheduledExecutorService mailer = Executors.newScheduledThreadPool(1)) {

    cartReminder = mailer.schedule(() -> "reminder for cart 7", 50, TimeUnit.MILLISECONDS);
    discarded = mailer.schedule(() -> System.out.println("never printed"), 30, TimeUnit.MINUTES);
    discarded.cancel(false);
}

String mail = cartReminder.resultNow();         // "reminder for cart 7"
boolean cancelled = discarded.isCancelled();    // true

The Callable version returns a value through the future, and the cancelled task never ran. Without the cancel() call, closing the scheduler would wait the full 30 minutes, because delayed one-time tasks still run after shutdown() by default, as section 5 explains.

3. scheduleAtFixedRate vs scheduleWithFixedDelay

Both methods repeat a task until it is cancelled, the executor shuts down or the task throws an exception. The difference is where the period is measured from. A fixed rate measures from the planned start of one run to the start of the next, while a fixed delay measures from the end of one run to the start of the next.

Timeline comparing scheduleAtFixedRate and scheduleWithFixedDelay for a 3 second task with a 10 second period
With a 3 second task, a 10 second fixed rate starts runs at 0, 10 and 20, while a 10 second fixed delay starts them at 0, 13 and 26

Runs of the same periodic task never overlap. If one run of a fixed rate task takes longer than the period, the next run starts late, right after the slow one ends, and the JDK never starts a second copy in parallel.

QuestionscheduleAtFixedRatescheduleWithFixedDelay
Period measuredStart to startEnd to start
Task of 3 s, period 10 sStarts at 0, 10, 20 sStarts at 0, 13, 26 s
Task slower than the periodNext run starts right after it, runs catch upGap stays the same
Good forMetrics, clocks, sampling at a steady pacePolling a queue or an inbox, jobs that must rest between runs

A monitoring agent that reports CPU use every 10 seconds wants a steady rate, so its charts have evenly spaced points. A job that polls a mail server wants a fixed delay, so a slow server never gets a new request before it answered the last one.

CountDownLatch threeRuns = new CountDownLatch(3);
ScheduledExecutorService poller = Executors.newSingleThreadScheduledExecutor();
ScheduledFuture<?> polling = poller.scheduleWithFixedDelay(
        threeRuns::countDown, 0, 10, TimeUnit.MILLISECONDS);
boolean reached = threeRuns.await(2, TimeUnit.SECONDS);   // true
polling.cancel(false);
poller.close();

4. What Happens When a Periodic Task Throws an Exception

If any run of a periodic task throws an exception, later runs are suppressed. The scheduler stores the exception in the future and never runs the task again, and nothing is printed, so a cache refresh can stop at night and nobody notices until the data is stale.

AtomicInteger refreshes = new AtomicInteger();
ScheduledExecutorService cache = Executors.newSingleThreadScheduledExecutor();
ScheduledFuture<?> refresh = cache.scheduleAtFixedRate(() -> {
    if (refreshes.incrementAndGet() == 3) {
        throw new IllegalStateException("source down");
    }
}, 0, 10, TimeUnit.MILLISECONDS);
Object done = refresh.get();                    // ExecutionException: IllegalStateException
cache.close();
int runs = refreshes.get();                     // 3
Future.State state = refresh.state();           // FAILED

The third run threw, and the task did not run again. The fix is a try/catch inside the task that logs the error and returns, so the next run still happens.

AtomicInteger attempts = new AtomicInteger();
CountDownLatch fiveRuns = new CountDownLatch(5);
ScheduledExecutorService safeCache = Executors.newSingleThreadScheduledExecutor();
safeCache.scheduleAtFixedRate(() -> {
    try {
        if (attempts.incrementAndGet() == 3) {
            throw new IllegalStateException("source down");
        }
    } catch (RuntimeException e) {
        System.err.println("refresh failed, retrying on the next run");
    } finally {
        fiveRuns.countDown();
    }
}, 0, 10, TimeUnit.MILLISECONDS);
boolean keptRunning = fiveRuns.await(2, TimeUnit.SECONDS);   // true
safeCache.close();

5. Cancelling Tasks and Shutting Down

We stop one periodic task with cancel() on its future, and the whole scheduler with shutdown(), shutdownNow() or close(). A ScheduledThreadPoolExecutor treats the two kinds of tasks differently at shutdown, and both defaults can be changed.

SettingDefaultEffect
setExecuteExistingDelayedTasksAfterShutdownPolicytrueOne-time delayed tasks still run after shutdown()
setContinueExistingPeriodicTasksAfterShutdownPolicyfalsePeriodic tasks are cancelled by shutdown()
setRemoveOnCancelPolicyfalseA cancelled task stays in the queue until its time comes

The last setting matters for apps that schedule many timeouts and cancel most of them, such as a request timeout per HTTP call. Each cancelled task stays in memory until its delay ends, which can be hours.

ScheduledThreadPoolExecutor timeouts = new ScheduledThreadPoolExecutor(1);
timeouts.schedule(() -> {}, 1, TimeUnit.HOURS).cancel(false);
int keptByDefault = timeouts.getQueue().size();     // 1
timeouts.setRemoveOnCancelPolicy(true);
timeouts.schedule(() -> {}, 1, TimeUnit.HOURS).cancel(false);
int afterPolicy = timeouts.getQueue().size();       // 1
timeouts.shutdownNow();

The queue still holds the first task, cancelled before we changed the policy, but the second one was removed at once. The task cancellation article covers cancel() in detail, and the ExecutorService shutdown article shows the shutdown sequence for long-lived executors.

6. Running a Task at a Fixed Time Every Day

The interface has no method that takes a time of day, so we compute the delay until the next run with java.time. A daily report at 02:00 is a common example.

static long secondsUntil(LocalTime time, ZonedDateTime now) {
    ZonedDateTime next = now.with(time);
    if (!next.isAfter(now)) {
        next = next.plusDays(1);
    }
    return Duration.between(now, next).toSeconds();
}
ZoneId paris = ZoneId.of("Europe/Paris");
ZonedDateTime evening = ZonedDateTime.of(2026, 10, 10, 22, 30, 0, 0, paris);
long untilReport = secondsUntil(LocalTime.of(2, 0), evening);       // 12600
ZonedDateTime beforeDst = ZonedDateTime.of(2026, 10, 24, 3, 0, 0, 0, paris);
long acrossDst = secondsUntil(LocalTime.of(3, 0), beforeDst);       // 90000

The second call shows why scheduleAtFixedRate(job, delay, 24, TimeUnit.HOURS) is the wrong tool for a daily job. Summer time in Paris ends on 25 October 2026, so that day has 25 hours, and a fixed 24-hour rate would move the job from 03:00 to 02:00 local time. The safe pattern schedules one run with schedule() and, at the end of each run, schedules the next one with a freshly computed delay.

For cron-style schedules, many jobs or jobs that must survive a restart, a scheduling library fits better, as section 8 shows.

7. Choosing the Number of Scheduler Threads

A single-thread scheduler runs every task on one thread. If one task takes a minute, every other task due in that minute starts late. The method newScheduledThreadPool(n) gives n threads, and its maximum pool size has no effect, because the scheduler uses an unbounded delay queue.

A cleaner design keeps the scheduler for timing only and hands the real work to another executor, for example one with virtual threads for jobs that call remote services.

AtomicInteger syncs = new AtomicInteger();
CountDownLatch twoSyncs = new CountDownLatch(2);
ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor();
ScheduledExecutorService clock = Executors.newSingleThreadScheduledExecutor();
clock.scheduleAtFixedRate(() -> workers.submit(() -> {
    syncs.incrementAndGet();
    twoSyncs.countDown();
}), 0, 10, TimeUnit.MILLISECONDS);
boolean synced = twoSyncs.await(2, TimeUnit.SECONDS);   // true
clock.close();
workers.close();

The clock thread only submits tasks, so a slow sync never delays the next tick. We close the clock first, so no new syncs arrive while the worker executor waits for the running ones. Since Java 25, ForkJoinPool also implements ScheduledExecutorService, so ForkJoinPool.commonPool().schedule(…) works without creating a scheduler at all.

8. Alternatives to ScheduledExecutorService

A ScheduledExecutorService keeps its tasks in memory, so a restart loses them, and it knows nothing about other instances of the app. Other tools cover those needs.

ToolUse it when
java.util.TimerNever in new code. One thread runs all tasks, and one exception kills the timer
ScheduledExecutorServiceIn-memory delays and periodic jobs inside one JVM
Spring @ScheduledA Spring Boot application with cron expressions in configuration
QuartzPersistent jobs, cron calendars and one run per cluster

9. ScheduledExecutorService FAQs

Overlapping runs, silent stops and missing time-of-day support bring most readers to these questions.

9.1. Can runs of scheduleAtFixedRate overlap?

No. The executor starts the next run of a periodic task only after the current run finishes. When a run takes longer than the period, the next one starts late, and with several threads in the pool the same task still never runs twice at the same time.

9.2. Why did my scheduled task stop running?

Most likely, a run threw an exception. The scheduler suppresses all later runs of that task and keeps the exception in the ScheduledFuture. Catch exceptions inside the task, or call get() on the future from a monitoring thread to see the cause.

9.3. How do we stop a periodic task without stopping the executor?

Call cancel(false) on the ScheduledFuture that scheduleAtFixedRate() or scheduleWithFixedDelay() returned. A run that is in progress finishes, and no new run starts. Use cancel(true) to also interrupt the running one.

9.4. Is ScheduledExecutorService better than Timer?

Yes. A Timer runs all tasks on one thread and stops completely when any task throws an unchecked exception, and it uses the system clock for its schedule. A ScheduledExecutorService can use several threads, keeps other tasks running when one fails and measures delays with System.nanoTime(), so clock changes do not affect it.

9.5. Does a scheduled task run on a virtual thread?

Not by default. The threads of newScheduledThreadPool() are platform threads. We can pass Thread.ofVirtual().factory() to newScheduledThreadPool(n, factory), but that pools virtual threads, which they are not designed for. The better design keeps platform threads for timing and submits the work to a virtual thread executor, as in section 7.

10. Conclusion

A ScheduledExecutorService runs tasks after a relative delay or periodically. A fixed rate keeps a steady start-to-start pace and a fixed delay keeps a steady gap between runs, and in both cases runs of one task never overlap.

Every periodic task needs its own try/catch, because one exception suppresses all later runs. Daily jobs get a fresh delay from java.time for every run, and jobs that must survive a restart belong in Spring or Quartz. The ExecutorService guide and the Java concurrency overview cover the executors this interface builds on.

11. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. Hi,

    I want to know why it is required to interrupt in catch block after call to shutdownNow().

    catch (InterruptedException ie) {
    executorService.shutdownNow();
    Thread.currentThread().interrupt();
    }

    • Calling Thread.currentThread().interrupt() sets the interrupt flag on the current thread, indicating that it has been interrupted. This flag is checked by various blocking methods and allows higher-level code or callers to check the interrupt flag and handle the interruption as needed.

  2. Hi Author,

    Thanks for writing such nice and lucid article. I want to ask if executorService.awaitTermination(1, TimeUnit.HOURS) is stuck for one hour and only return true and false value after hour.

  3. That is a good explanation with excellent examples, thanks for that!

    One question, though, when using the scheduleWithFixedDelay/Rate() methods: if you want to stop the recurrent behaviour you have to use the shutdown() method.

    However, if you in the meantime use the Future::get() method, which blocks until the rescheduling of tasks is finished due to the shutdown() method, you will get a CancellationException.

    How do you have to avoid this?

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.