Introduction
StreamingResponseBody in Spring Boot writes directly to the OutputStream on a separate thread, allowing large file downloads without loading the entire file into memory. However, Spring's async request timeout (default 30 seconds) can terminate the download before it finishes. The fix is to increase or disable the async timeout via spring.mvc.async.request-timeout or by configuring AsyncSupportConfigurer.
The Problem
1@GetMapping("/download")
2public ResponseEntity<StreamingResponseBody> downloadFile() {
3 StreamingResponseBody body = outputStream -> {
4 // Large file — takes more than 30 seconds to stream
5 try (InputStream in = new FileInputStream("/data/large-file.zip")) {
6 byte[] buffer = new byte[8192];
7 int bytesRead;
8 while ((bytesRead = in.read(buffer)) != -1) {
9 outputStream.write(buffer, 0, bytesRead);
10 outputStream.flush();
11 }
12 }
13 };
14
15 return ResponseEntity.ok()
16 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=large-file.zip")
17 .contentType(MediaType.APPLICATION_OCTET_STREAM)
18 .body(body);
19}
After 30 seconds, Spring terminates the async request and the client receives a truncated file or a timeout error.
Fix 1: Application Properties
1# application.properties
2# Set timeout in milliseconds (-1 to disable)
3spring.mvc.async.request-timeout=600000
4# 600000ms = 10 minutes
5
6# Or disable timeout entirely
7spring.mvc.async.request-timeout=-1
1@Configuration
2public class WebConfig implements WebMvcConfigurer {
3
4 @Override
5 public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
6 // 10 minutes in milliseconds
7 configurer.setDefaultTimeout(600_000);
8
9 // Or use a custom task executor
10 ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
11 executor.setCorePoolSize(5);
12 executor.setMaxPoolSize(10);
13 executor.setQueueCapacity(25);
14 executor.setThreadNamePrefix("stream-");
15 executor.initialize();
16 configurer.setTaskExecutor(executor);
17 }
18}
Fix 3: Per-Endpoint Timeout with Callable
For different timeouts on different endpoints:
1@GetMapping("/download")
2public Callable<ResponseEntity<StreamingResponseBody>> downloadFile() {
3 return () -> {
4 StreamingResponseBody body = outputStream -> {
5 // Stream the file...
6 };
7 return ResponseEntity.ok()
8 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=file.zip")
9 .body(body);
10 };
11}
Configure the timeout on the WebAsyncTask wrapper:
1@GetMapping("/download")
2public WebAsyncTask<ResponseEntity<StreamingResponseBody>> downloadFile() {
3 // 10 minutes for this endpoint only
4 WebAsyncTask<ResponseEntity<StreamingResponseBody>> task = new WebAsyncTask<>(600_000L, () -> {
5 StreamingResponseBody body = outputStream -> {
6 try (InputStream in = new FileInputStream("/data/large-file.zip")) {
7 byte[] buffer = new byte[8192];
8 int bytesRead;
9 while ((bytesRead = in.read(buffer)) != -1) {
10 outputStream.write(buffer, 0, bytesRead);
11 }
12 }
13 };
14 return ResponseEntity.ok()
15 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=file.zip")
16 .contentType(MediaType.APPLICATION_OCTET_STREAM)
17 .body(body);
18 });
19
20 task.onTimeout(() -> ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).build());
21 return task;
22}
Complete Download Endpoint with Progress
1@GetMapping("/download/{filename}")
2public ResponseEntity<StreamingResponseBody> downloadFile(@PathVariable String filename) {
3 Path filePath = Paths.get("/data/files", filename);
4
5 if (!Files.exists(filePath)) {
6 return ResponseEntity.notFound().build();
7 }
8
9 long fileSize;
10 try {
11 fileSize = Files.size(filePath);
12 } catch (IOException e) {
13 return ResponseEntity.internalServerError().build();
14 }
15
16 StreamingResponseBody body = outputStream -> {
17 try (InputStream in = Files.newInputStream(filePath)) {
18 byte[] buffer = new byte[16384]; // 16KB buffer
19 int bytesRead;
20 while ((bytesRead = in.read(buffer)) != -1) {
21 outputStream.write(buffer, 0, bytesRead);
22 outputStream.flush(); // Flush regularly for streaming
23 }
24 }
25 };
26
27 return ResponseEntity.ok()
28 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
29 .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileSize))
30 .contentType(MediaType.APPLICATION_OCTET_STREAM)
31 .body(body);
32}
Embedded Tomcat Connection Timeout
Besides Spring's async timeout, Tomcat has its own connection timeout:
1# application.properties
2# Tomcat connection timeout (default 20s for idle connections)
3server.tomcat.connection-timeout=600000
4
5# Keep-alive timeout
6server.tomcat.keep-alive-timeout=600000
Using Resource Instead of StreamingResponseBody
For simple file downloads, Resource may be simpler:
1@GetMapping("/download/{filename}")
2public ResponseEntity<Resource> downloadResource(@PathVariable String filename) throws IOException {
3 Path path = Paths.get("/data/files", filename);
4 Resource resource = new FileSystemResource(path);
5
6 return ResponseEntity.ok()
7 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
8 .contentLength(Files.size(path))
9 .contentType(MediaType.APPLICATION_OCTET_STREAM)
10 .body(resource);
11}
Resource-based responses are handled by Spring's built-in ResourceHttpMessageConverter, which streams efficiently. However, StreamingResponseBody gives you more control over the writing process (progress tracking, transformation, etc.).
Common Pitfalls
Not setting Content-Length: Without the Content-Length header, browsers cannot show download progress. Set it with .contentLength(fileSize) or .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileSize)) when the size is known.
Forgetting about reverse proxy timeouts: Nginx, Apache, and cloud load balancers have their own timeout settings. Even if Spring is configured for 10 minutes, Nginx's default proxy_read_timeout of 60 seconds will kill the connection. Set proxy_read_timeout 600s; in the Nginx config.
Not flushing the output stream: Without outputStream.flush() inside the write loop, the servlet container buffers the response. This delays the start of the download and uses more memory. Flush after each write or every few kilobytes.
Using StreamingResponseBody for small files: For files under a few megabytes, StreamingResponseBody adds unnecessary complexity. Use Resource or byte[] responses instead — they are simpler and handle the response in one pass.
Thread pool exhaustion: Each StreamingResponseBody request occupies a thread for the entire download duration. With many concurrent downloads, the thread pool can be exhausted. Configure a dedicated TaskExecutor with enough threads, or use reactive WebFlux for truly non-blocking streaming.
Summary
StreamingResponseBody streams large files without loading them into memory
Spring's async timeout (default 30s) kills long-running downloads — set spring.mvc.async.request-timeout=-1 or a higher value
Also configure Tomcat's connection-timeout and any reverse proxy timeouts (Nginx, ALB)
Set Content-Length header so browsers can show download progress
Flush the output stream regularly during writes to avoid buffering
Use a dedicated thread pool for download endpoints to prevent thread exhaustion