Mastering Asynchronous Responses in Spring: CompletableFuture vs ResponseBodyEmitter
This guide explains how to implement high‑performance asynchronous APIs in Spring Web using CompletableFuture and ResponseBodyEmitter, covering controller and service code, configuration, request lifecycle, client handling, Nginx settings, and practical recommendations for choosing the right approach.
Introduction
When building high‑performance web applications, asynchronous response mechanisms are key to improving throughput and user experience. This article explores two common Spring Web async techniques— CompletableFuture and ResponseBodyEmitter —and provides a complete practical guide, including front‑end interaction, Nginx configuration, and connection management.
1. Using CompletableFuture for Async Endpoints
Controller Code
@RestController
public class AsyncController {
@Autowired
private AsyncService asyncService;
@GetMapping("/async")
public CompletableFuture<String> async() {
return asyncService.doAsyncTask();
}
}Service Layer Code
@Service
public class AsyncService {
@Async
public CompletableFuture<String> doAsyncTask() {
try {
Thread.sleep(3000); // simulate long‑running task
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return CompletableFuture.completedFuture("Task completed!");
}
}Enable Async Support
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
return Executors.newCachedThreadPool();
}
}Explanation
@Async: Executes the method in a separate thread.
CompletableFuture: Container for the async result; Spring suspends the request until completion.
@EnableAsync: Turns on Spring’s async method support.
taskExecutor(): Provides a thread pool to avoid creating a new thread for each request.
Spring MVC CompletableFuture Response Flow
Server‑side processing
Client sends a request to DispatcherServlet.
Controller returns a CompletableFuture. Spring detects the return type, suspends the request, and waits for the async task.
The async task runs in the thread pool defined by @Async and TaskExecutor.
When the task finishes, Spring serializes the CompletableFuture result (JSON or plain text) and sends it back.
After the response is sent, the HTTP connection is closed.
Client experience
The client receives a normal HTTP response; no special handling is required, though the response time may be slightly longer.
Benefits of CompletableFuture
Increases system throughput and concurrency by releasing the servlet thread while the task runs.
Provides a non‑blocking programming model and supports chaining methods such as .thenApply() and .thenCompose().
Improves user experience with shorter perceived latency for parallel tasks.
Works well with @Async to decouple business logic.
Allows graceful error handling and timeout control, e.g.:
return asyncService.doAsyncTask()
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> "Task failed: " + ex.getMessage());2. Using ResponseBodyEmitter for Streaming Push
ResponseBodyEmitterenables the server to send data in chunks within a single HTTP request, suitable for long‑living connections or real‑time updates.
Controller Code
@RestController
public class StreamController {
@Autowired
private EmitterManager emitterManager;
@GetMapping("/stream/{id}")
public ResponseBodyEmitter stream(@PathVariable String id) {
ResponseBodyEmitter emitter = new ResponseBodyEmitter();
emitterManager.register(id, emitter);
Executors.newSingleThreadExecutor().submit(() -> {
try {
for (int i = 0; i < 10; i++) {
if (emitterManager.shouldStop(id)) break;
emitter.send("Progress: " + i * 10 + "%
", MediaType.TEXT_PLAIN);
Thread.sleep(1000);
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
@PostMapping("/stop/{id}")
public ResponseEntity<Void> stop(@PathVariable String id) {
emitterManager.stop(id);
return ResponseEntity.ok().build();
}
}Explanation
ResponseBodyEmitter: Allows multiple data chunks to be sent in one request.
emitter.send(): Sends each chunk; the client receives data in real time.
emitter.complete(): Marks the end of the stream and closes the connection.
emitter.completeWithError(): Closes the connection on error.
EmitterManager: Manages active emitter sessions and supports external stop signals.
Key Considerations
Default timeout is 30 seconds; can be changed via the constructor.
Async thread must explicitly call complete() or completeWithError().
Core Features
Non‑blocking asynchronous response.
Supports multiple writes per request.
Compatible with Servlet 3.0+ async processing.
Can be combined with @ResponseBody or @RestController.
Front‑end Consumption
Using fetch with a ReadableStream:
fetch('/progress/stream')
.then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({ done, value }) => {
if (done) return;
console.log(decoder.decode(value));
read();
});
}
read();
});Alternatively, EventSource can be used for text/event-stream responses:
const source = new EventSource('/sse/stream');
source.onmessage = event => console.log('Received:', event.data);Nginx Configuration for Streaming
location /stream {
proxy_pass http://localhost:8080;
proxy_buffering off; # disable buffering for real‑time push
proxy_read_timeout 3600s; # long‑connection timeout
chunked_transfer_encoding on; # enable chunked transfer
}EmitterManager and EmitterSession
@Component
public class EmitterManager {
private final Map<String, EmitterSession> sessions = new ConcurrentHashMap<>();
public void register(String id, ResponseBodyEmitter emitter) {
EmitterSession session = new EmitterSession(id, emitter);
sessions.put(id, session);
emitter.onCompletion(() -> cleanup(id));
emitter.onTimeout(() -> cleanup(id));
emitter.onError(t -> cleanup(id));
}
public void stop(String id) {
EmitterSession session = sessions.get(id);
if (session != null) {
session.getStopFlag().set(true);
session.getEmitter().complete();
cleanup(id);
}
}
public boolean shouldStop(String id) {
EmitterSession session = sessions.get(id);
return session != null && session.getStopFlag().get();
}
public ResponseBodyEmitter getEmitter(String id) {
EmitterSession session = sessions.get(id);
return session != null ? session.getEmitter() : null;
}
private void cleanup(String id) {
sessions.remove(id);
}
}
public class EmitterSession {
private final String id;
private final ResponseBodyEmitter emitter;
private final AtomicBoolean stopFlag;
private final Instant connectedAt;
public EmitterSession(String id, ResponseBodyEmitter emitter) {
this.id = id;
this.emitter = emitter;
this.stopFlag = new AtomicBoolean(false);
this.connectedAt = Instant.now();
}
public String getId() { return id; }
public ResponseBodyEmitter getEmitter() { return emitter; }
public AtomicBoolean getStopFlag() { return stopFlag; }
public Instant getConnectedAt() { return connectedAt; }
}3. Comparison: CompletableFuture vs ResponseBodyEmitter
**Usage Recommendations**
Use CompletableFuture for single‑result asynchronous operations such as remote calls, database queries, or CPU‑bound tasks.
Choose ResponseBodyEmitter when you need to push incremental data, stream progress updates, or implement server‑sent events.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Selected Java Interview Questions
A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
