Spring Boot Large File Download: Streaming & Range Requests for Resumable Transfers
The article shows how to handle 10GB file downloads in Spring Boot by replacing in-memory byte[] loading with streaming via FileSystemResource and leveraging Spring MVC's built-in Range request support for resumable downloads, while covering security, immutability, and object storage offloading.
Problem: In-Memory Byte Array Causes OOM
The initial download endpoint used Files.readAllBytes(path) to load the entire file into a byte[] before returning it via ResponseEntity. This worked for small files (KB to MB) but became catastrophic when files grew to hundreds of megabytes or gigabytes. A 10 GB file would require a 10 GB contiguous heap allocation, which most Spring Boot services cannot sustain.
First Improvement: Streaming with InputStream.transferTo
Switching to a streaming approach avoids loading the whole file into memory:
@GetMapping("/files/{id}/download")
public void download(@PathVariable Long id, HttpServletResponse response) throws IOException {
FileInfo file = fileService.findById(id);
Path path = Path.of(file.getPath());
response.setContentType("application/octet-stream");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"");
try (InputStream input = Files.newInputStream(path);
OutputStream output = response.getOutputStream()) {
input.transferTo(output);
}
}Memory stays stable because data flows directly from disk to the socket. However, this still lacks resumable download support: a network interruption forces the client to restart from 0 %.
Resumable Downloads via HTTP Range Requests
HTTP Range requests (RFC 9110) solve the restart problem. The client sends a Range: bytes=104857600- header indicating the byte offset it already has. The server responds with 206 Partial Content, a Content-Range header, and Accept-Ranges: bytes. The client appends the received segment to the existing file.
Spring MVC Native Range Support
Spring MVC automatically parses the Range header and serves partial content when the controller returns a Resource (not an InputStreamResource) or a ResponseEntity with status 200. The framework handles the 206 response and Content-Range calculation.
Correct implementation using FileSystemResource:
@RestController
@RequestMapping("/api/files")
public class FileDownloadController {
private final FileService fileService;
public FileDownloadController(FileService fileService) {
this.fileService = fileService;
}
@GetMapping("/{id}/download")
public ResponseEntity<Resource> download(@PathVariable Long id) throws IOException {
FileInfo file = fileService.findById(id);
Path path = Path.of(file.getStoragePath());
if (!Files.exists(path) || !Files.isRegularFile(path)) {
throw new FileNotFoundException("file not found");
}
Resource resource = new FileSystemResource(path);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(file.getOriginalName(), StandardCharsets.UTF_8)
.build().toString())
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.body(resource);
}
}Key points:
No byte[], no Files.readAllBytes(), no manual transferTo. FileSystemResource allows random access required for Range requests; InputStreamResource does not.
Spring sets Accept-Ranges: bytes and processes Range headers automatically.
Testing Range Requests
Full download: curl -v http://localhost:8080/api/files/100/download Partial segment:
curl -v -H "Range: bytes=1048576-2097151" http://localhost:8080/api/files/100/downloadResumable with curl:
curl -C - -O http://localhost:8080/api/files/100/downloadSecurity: Avoid Path Traversal and Missing Authorization
Never accept a file path directly from the client (e.g., GET /download?path=/data/files/a.zip). Instead, use an opaque fileId and resolve the physical path server-side after authorization checks:
public FileInfo findDownloadableFile(Long userId, Long fileId) {
return fileRepository.findByIdAndUserId(fileId, userId)
.orElseThrow(FileNotFoundException::new);
}For multi-tenant systems, include tenantId in the query. This prevents unauthorized access by guessing file IDs.
File Immutability During Resumable Download
If the underlying file is replaced while a download is in progress, the resumed segments will belong to a different version, corrupting the result. Use immutable storage keys: each upload creates a new storage_key (e.g., with a UUID or content hash) rather than overwriting the same path. Combine with ETag or Last-Modified and the If-Range header so the client can verify the resource hasn't changed before resuming.
Memory Model Comparison
Byte-array approach: Disk → huge byte[] in JVM heap → socket. Heap grows linearly with file size.
Streaming approach: Disk → small read buffers → socket. Heap remains constant regardless of file size.
Eliminate any code that materializes the entire file in memory: Files.readAllBytes, resource.getContentAsByteArray(), ByteArrayOutputStream, or byte[] fileContent.
Zero-Copy Optimization with FileChannel.transferTo
For extreme throughput or custom range handling, JDK's FileChannel.transferTo can transfer directly from file to socket channel, potentially leveraging OS zero-copy. Example:
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ);
WritableByteChannel output = Channels.newChannel(response.getOutputStream())) {
long position = start;
long remaining = length;
while (remaining > 0) {
long transferred = fileChannel.transferTo(position, remaining, output);
if (transferred <= 0) break;
position += transferred;
remaining -= transferred;
}
}However, prefer Spring's built-in Range handling first; only drop to this level if benchmarks show insufficient throughput, high CPU, or special throttling/storage protocols.
Offloading to Object Storage (S3, OSS, COS, MinIO)
For very large files (hundreds of GB) or high concurrency, routing the file through the Java application wastes bandwidth and CPU. Better architecture:
Client requests download.
Spring Boot performs authorization.
Generate a short-lived signed URL (e.g., 5 minutes) for the object storage.
Client downloads directly from object storage/CDN.
This keeps the Java service lightweight and shifts data transfer to infrastructure optimized for it.
Rate Limiting and Concurrency Controls
Even with streaming, unlimited concurrent downloads can saturate disk I/O, network bandwidth, connection pools, and threads. Apply limits at the gateway, CDN, or object storage layer:
Per-user concurrent downloads (e.g., 2 for free tier, 10 for enterprise).
Per-tenant concurrency caps.
Bandwidth throttling.
Download audit logging.
Conclusion
The core issue is not JVM tuning ( -Xmx, Tomcat swallow size, Nginx timeouts) but adapting the code model to the data scale. The final flow for local files:
Client → fileId → Auth → Metadata lookup → FileSystemResource → Spring MVC Range handling → 206 Partial Content → Client resumable downloadFor object storage:
Client → Spring Boot auth → Signed URL → S3/OSS/COS/MinIO → Client direct downloadBoth approaches ensure a 10 GB file never becomes a 10 GB byte[] in the JVM heap, and both support resumable downloads so a 9 GB partial transfer is not lost on network interruption.
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.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
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.
