Java Callable, Future & ScheduledExecutorService

27 Jul 2026 9 min read
2
In this article, we will learn when to use Callable instead of Runnable, understand how Future represents the result of an asynchronous computation, and explore how ScheduledExecutorService simplifies delayed and periodic task execution.

Limitations of Runnable

Throughout the previous chapters, we used the Runnable interface to represent units of work executed by a thread or an ExecutorService.
Runnable task = () -> {
    System.out.println("Processing order...");
};
The Runnable interface is intentionally simple.
public interface Runnable {
    void run();
}
Its run() method has a void return type and does not declare any checked exceptions. This design makes Runnable ideal for background tasks that simply perform some work without producing a result.

However, a task may also need to calculate and return a result. Since Runnable cannot return a value, developers often resort to storing the result in shared variables or mutable objects.

This makes the code more complex and increases the risk of synchronization issues. Another limitation is that the run() method cannot throw checked exceptions.

If a database query or file operation fails, the task must catch the exception internally, making it difficult for the calling code to determine whether the task completed successfully.

These limitations make Runnable unsuitable for many asynchronous computations where returning results and propagating exceptions are important.

Introducing Callable

To overcome these limitations, Java introduced the Callable interface as part of the java.util.concurrent package.

Unlike Runnable, a Callable represents a task that returns a result and may throw checked exceptions.
public interface Callable {
    V call() throws Exception;
}
Here, V represents the type of value returned by the task. This generic design allows a Callable to return any type of object. A simple example is shown below.
Callable task = () -> {
    return 100;
};
When executed, this task returns an Integer instead of simply performing an action.

Another important advantage is that the call() method is allowed to throw checked exceptions. A simple example is reading a file asynchronously.

Since Files.readString() throws the checked exception IOException, Callable can propagate it directly.
Callable readFile = () -> {
    return Files.readString(Path.of("config.txt"));
};
When this Callable is submitted to an ExecutorService, any exception thrown by call() is captured by the Future and can be handled by the calling thread:
Future future = executor.submit(readFile);
try {
    String content = future.get();
    System.out.println(content);
} catch (Exception e) {
    System.out.println(e.getCause());
}
This allows the asynchronous task to propagate checked exceptions instead of catching and suppressing them internally, making error handling much cleaner and more reliable.

Future

When a Callable task is submitted to an ExecutorService, the task executes asynchronously on one of the worker threads. Since the computation may not complete immediately, the calling thread cannot obtain the result directly.

Instead, the submit() method returns a Future, which represents the result of an asynchronous computation that may become available at some point in the future.

A Future acts as a placeholder for the result of the task. It allows the calling thread to monitor the task's execution, wait for its completion, retrieve the computed result, cancel the task if necessary, and determine whether the task has already finished.

The relationship between Callable and Future can be visualized as follows:

Submitting a Callable Task

When a Callable is submitted using the submit() method, the method immediately returns a Future while the task continues executing in the background.
Future future = executor.submit(() -> {
    Thread.sleep(3000);
    return 100;
});
Notice that the main thread does not wait for the computation to finish. Instead, it immediately receives a Future, which can later be used to obtain the result once the background task completes.

Retrieving the Result with get()

The most commonly used method of the Future interface is get().
Integer result = future.get();
The get() method waits until the asynchronous computation completes and then returns the computed value. For example:
ExecutorService executor = Executors.newFixedThreadPool(2);
Future future = executor.submit(() -> {
    Thread.sleep(3000);
    return 100;
});

System.out.println("Doing other work...");
Integer value = future.get();
System.out.println(value);
executor.shutdown();
Possible output:
Doing other work... 
100 
While the worker thread performs the computation, the main thread is free to execute other work. Only when future.get() is called does the main thread wait for the computation to finish.

One important characteristic of Future.get() is that it is a blocking method. If the computation has not yet completed, the calling thread remains blocked until the result becomes available.

Waiting with a Timeout

Sometimes waiting indefinitely is undesirable.

For example, a remote API call may never respond due to a network failure. To avoid blocking forever, Future provides a timeout version of get().
 Integer result = future.get(5, TimeUnit.SECONDS); 
If the computation completes within five seconds, the result is returned normally. Otherwise, a TimeoutException is thrown.

This prevents the application from waiting indefinitely for a slow or unresponsive task.

Checking Completion with isDone()

Instead of immediately blocking using get(), an application can first determine whether the task has already completed.
Future future = executor.submit(() -> {
    Thread.sleep(5000);
    return "Monthly report generated";
});

while (!future.isDone()) {
    System.out.println("Main thread is doing other work...");
    Thread.sleep(1000);
}

System.out.println("Task completed");
System.out.println(future.get());
Output:
Main thread is doing other work...
Main thread is doing other work...
Main thread is doing other work...
Task completed
Monthly report generated
The isDone() method returns true if the task has completed, regardless of whether it finished successfully, threw an exception, or was cancelled.

Cancelling a Task

Sometimes a submitted task is no longer needed before it completes. For example, a user may cancel a report generation request or close a screen before the background computation finishes.

The Future interface allows tasks to be cancelled using the cancel() method.
 future.cancel(true); 
The boolean parameter determines how cancellation should behave.

If true is passed, the executor attempts to interrupt the thread currently executing the task. If false is passed, the task is cancelled only if it has not yet started. A running task is allowed to continue normally.

Example:
Future future = executor.submit(() -> {
    Thread.sleep(10000);
    return 100;
});

future.cancel(true);
If the task responds to interruption, it can terminate early instead of running for the full ten seconds.

After attempting cancellation, the application can determine whether the task was successfully cancelled using isCancelled().
if (future.isCancelled()) {
    System.out.println("Task cancelled");
}
This allows the application to distinguish between completed tasks and cancelled tasks.

Complete Example

Together, these methods allow applications to retrieve results, wait for completion, specify timeouts, cancel running tasks, and monitor task status.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class FutureExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        Future future = executor.submit(() -> {
            Thread.sleep(2000);
            return 500;
        });
        System.out.println("Main thread continues...");

        Integer result = future.get();
        System.out.println("Result = " + result);

        executor.shutdown();
    }
}
Possible output:
Main thread continues...
Result = 500

ScheduledExecutorService

Many applications need to execute tasks not immediately, but at a later time or repeatedly at fixed intervals.

Examples include sending periodic heartbeat messages, refreshing application caches, generating reports every night, monitoring system health, or retrying failed operations after a delay.

While it is possible to implement such functionality manually using Thread.sleep(), this approach is inflexible, blocks threads unnecessarily, and quickly becomes difficult to manage.

To solve these problems, Java provides the ScheduledExecutorService, which extends the ExecutorService interface with support for delayed and periodic task execution.

Instead of manually managing sleeping threads or timers, developers simply schedule tasks, and the framework ensures they execute at the appropriate time using a pool of worker threads.

Creating a Scheduled Thread Pool

A scheduled executor is created using the Executors utility class.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
Here, the scheduler maintains a pool of two worker threads that can execute delayed and periodic tasks concurrently.

Scheduling a Task Once

The schedule() method executes a task once after a specified delay.
scheduler.schedule(
    () -> System.out.println("Task executed"),
    5,
    TimeUnit.SECONDS
);
In this example, the task begins execution approximately five seconds after being scheduled.

During the waiting period, the worker thread is managed efficiently by the scheduler, allowing other tasks to execute if necessary.

This method is useful for retry operations, delayed notifications, timeout handling, and deferred background processing.

Executing Tasks Periodically

Many applications require the same task to execute repeatedly rather than only once.

Examples include refreshing cache entries every few minutes, collecting application metrics, or monitoring server health.

The ScheduledExecutorService provides two methods for periodic execution: scheduleAtFixedRate() and scheduleWithFixedDelay().

Although these methods appear similar, they behave differently and are designed for different use cases.
scheduleAtFixedRate()
The scheduleAtFixedRate() method attempts to execute a task at regular intervals measured from the scheduled start time of the previous execution.
scheduler.scheduleAtFixedRate(
    () -> System.out.println("Health Check"),
    2,
    5,
    TimeUnit.SECONDS
);
The first execution starts after two seconds. Subsequent executions are scheduled every five seconds regardless of how long the previous execution took, provided it completes before the next scheduled start time.

Its behavior can be visualized as follows:
Time ─────────────────────────────────────────▢

      2s       7s       12s      17s
      β”‚        β”‚         β”‚        β”‚
   β”Œβ”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”
   β”‚Task β”‚  β”‚Task β”‚   β”‚Task β”‚  β”‚Task β”‚
   β””β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”˜
This scheduling strategy is appropriate for tasks that should execute at regular intervals, such as monitoring systems, metric collection, or periodic polling.
scheduleWithFixedDelay()
The scheduleWithFixedDelay() method behaves differently.

Instead of measuring from the scheduled start time, it waits for the current execution to finish and then waits for the specified delay before starting the next execution.
scheduler.scheduleWithFixedDelay(
    () -> System.out.println("Refreshing cache"),
    2,
    5,
    TimeUnit.SECONDS
);
Its execution pattern looks like this:
Time ─────────────────────────────────────────────────────────▢

      2s        9s        16s        25s
      β”‚         β”‚          β”‚          β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”
   β”‚ Start β”‚ β”‚ Start β”‚ β”‚ Start β”‚ β”‚ Start β”‚
   β”‚ Task  β”‚ β”‚ Task  β”‚ β”‚ Task  β”‚ β”‚ Task  β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜
      β”‚         β”‚          β”‚          β”‚
    Took 2s   Took 2s    Took 4s    Took 2s
      β”‚         β”‚          β”‚          β”‚
    Wait 5s   Wait 5s    Wait 5s    Wait 5s
If one execution takes longer than expected, the next execution is delayed accordingly.

This makes scheduleWithFixedDelay() particularly suitable for maintenance tasks where each execution should complete fully before the next one begins.

Conclusion

The Callable, Future, and ScheduledExecutorService APIs greatly extend the capabilities of the Executor Framework by enabling asynchronous computations that return results and supporting delayed as well as periodic task execution.

Together, they provide a solid foundation for building responsive, concurrent applications. However, as application workflows become more complex, the limitations of Future become increasingly evident.

Modern Java applications therefore rely heavily on CompletableFuture, which offers a far more powerful and flexible approach to asynchronous programming.
Nagesh Chauhan

Nagesh Chauhan

Principal Software Engineer β€’ Java β€’ Python β€’ Distributed Systems β€’ AI/ML

Principal Software Engineer with 14+ years of experience designing and delivering large-scale distributed systems, cloud-native applications, and AI-powered platforms.

Passionate about solving complex engineering problems using strong data structures and algorithms, along with expertise in Java, Spring Boot, Python, System Design, Microservices, Cloud, Kafka, Elasticsearch, and Generative AI.

Share this Article

πŸ’¬ Comments

Join the Discussion