File Downloads in Spring Boot with Byte Streams

Image Source

Serving large files through a Spring Boot app comes up all the time. It could be for download links, shared exports, or giving access to data that’s been stored somewhere on the server. What makes it interesting is how Java handles the streaming behind the scenes, particularly when those files start to get big and the server needs to stay responsive without holding everything in memory.

I publish free articles like this daily, if you want to support my work and get access to exclusive content and weekly recaps, consider subscribing to my Substack.

File Download Mechanics with Byte Streams

There’s a big difference between sending a small image and delivering a multi-gigabyte backup file. On the surface, the behavior looks the same to the client, but behind the scenes, how you serve that file affects everything from memory use to overall app performance. Spring Boot has strong support for streaming data to clients, and the mechanics behind that are built on Java’s file handling and HTTP output streams. When files are streamed instead of fully loaded, you keep memory use stable and avoid blocking other requests on the server. This section looks at how that works from the moment the request arrives to the point the browser starts receiving the content.

Why Streaming Matters for Large Files

Applications that offer downloadable content sometimes fall into the trap of reading the full file into a byte array before sending it. While that works fine for small attachments, it creates real pressure when multiple users download larger files at the same time. It only takes a few hundred megabytes in memory to create backlogs that affect other parts of the app. Streaming avoids that by breaking the data into small chunks. These chunks are read and sent one at a time, without ever collecting the full file in memory. This is handled with a standard InputStream in Java. Instead of buffering the whole file, the server just reads the next piece, writes it to the HTTP output stream, then continues reading until it’s done or the client disconnects.

Here’s a quick look at what not to do when sending a large file:

@GetMapping("/download-unsafe")
public ResponseEntity<byte[]> badDownload() throws IOException {
Path file = Paths.get("big-file.zip");
byte[] content = Files.readAllBytes(file); // Bad idea for large files
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=big-file.zip");
return ResponseEntity.ok()
.headers(headers)
.body(content); // Sends entire file from memory
}

That loads the full file content into memory before sending the response. With large files, that can be risky. Instead, you want to let the file stream directly from disk to the client.

How Byte Streaming Works During a Request

Streaming starts with a controller method that returns an InputStreamResource or something similar. This signals to Spring that the body will be written as a stream, not as a fully buffered response. After headers are committed, Spring begins writing the body directly to the output stream tied to the HTTP response. That connection stays open while data is still being read.

Here’s a basic streaming download handler that reads from disk safely:

@GetMapping("/stream-download/{fileName}")
public ResponseEntity<Resource> streamDownload(@PathVariable String fileName) throws IOException {
Path downloadPath = Paths.get("downloadables").resolve(fileName).normalize();
Resource resource = new PathResource(downloadPath); // opens stream only when needed
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"");
return ResponseEntity.ok()
.headers(headers)
.contentLength(Files.size(downloadPath))
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}

This method doesn’t hold the entire file in memory. As data is read from the file system, it’s sent to the client through the response’s output stream. This also means the server doesn’t care how big the file is, because memory usage stays flat. The use of Files.newInputStream() here gives you a direct, on-demand feed from disk. This stream stays active during the response, and data is read only as needed. If a client cancels the download or loses connection, the stream gets closed early, and the server moves on without delay.

Spring handles this in a blocking way on the request thread, but unless the file is being read from a slow or remote location, that’s usually fast enough. For file-based downloads, it’s very common to stay synchronous and just let the stream do its work without extra complexity.

Setting the Response Headers Correctly

Streaming data is only part of the story. For the client to handle the file correctly, you need to set headers that tell the browser what to do. These headers make the browser treat the content as a downloadable file and show the correct filename in the save dialog. They also help clients track progress and know how large the file is before it completes.

The most important ones are:

  • Content-Disposition to suggest a filename and make the file downloadable
  • Content-Type to describe the file type or fall back to a generic binary type
  • Content-Length to let the client know how much data to expect

You don’t always need to calculate the size yourself. But if you’re reading from disk and the file size is known, setting Content-Length helps browsers show progress bars and prevents long downloads from hanging on timeout rules.

Here’s how to do this with a bit more control:

@GetMapping("/download-manual/{fileName}")
public void manualDownload(@PathVariable String fileName, HttpServletResponse response) throws IOException {
Path baseDir = Paths.get("storage").toAbsolutePath().normalize();
Path filePath = baseDir.resolve(fileName).normalize();
if (!filePath.startsWith(baseDir)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid file path");
}
String type = Files.probeContentType(filePath);
response.setContentType(type != null ? type : MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"");
response.setContentLengthLong(Files.size(filePath));
try (InputStream input = Files.newInputStream(filePath)) {
ServletOutputStream output = response.getOutputStream();
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = input.read(buffer)) != -1) {
output.write(buffer, 0, bytesRead);
}
output.flush();
}
}

This version doesn’t return a Spring-managed response. Instead, it works directly with the HttpServletResponse. This gives full control over headers and body streaming. It’s useful when you want to fine-tune the download flow or avoid wrapping things in a ResponseEntity.

Security With Path Normalization

Allowing users to download files by name can introduce risk if not handled carefully. A common trick is to try inserting sequences like ../ in the file path to escape the intended directory and access system files or configuration data. Without validation, this can let users reach outside the download folder and grab something they shouldn’t have access to.

Spring doesn’t block this automatically. You need to protect the file resolution logic by normalizing the path and checking that the final location still lives inside the allowed folder.

This simple check helps lock it down:

Path baseFolder = Paths.get("safe-downloads").toAbsolutePath().normalize();
Path targetFile = baseFolder.resolve(fileName).normalize();
if (!targetFile.startsWith(baseFolder)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid file path");
}

This prevents any path tricks from working. If the user tries something sneaky like ../../application.properties, the normalized result no longer starts with the base folder path, and the request gets rejected. It’s also a good idea to restrict file extensions or use a database of known allowed files if your use case supports it. But the basic path check above blocks most common attacks on its own.

How Spring Boot Streams Files Behind the Scenes

Returning a stream from a controller often feels easy to write, but there’s more happening behind the scenes than most people realize. The moment your controller hands off the stream, Spring’s internal web layer steps in to coordinate how the data flows, how the connection is handled, and how resources are managed.

Streaming with InputStreamResource

InputStreamResource is one of the options Spring provides when you want to send a file or large data stream to a client. What makes it helpful is that it doesn’t try to pull the full content into memory first. It wraps a raw InputStream and lets the system read from it as the response goes out. This works well when you’re sending a file directly from disk, but it also fits situations where you’re building data dynamically or connecting to another service that gives you a stream. Because there’s no internal buffering of the full body, the memory footprint stays low from start to finish.

Here’s a version that streams a PDF file located in a secured folder. It includes the content size and sets the headers so the client can download it:

@GetMapping("/reports/{reportId}")
public ResponseEntity<Resource> getReport(@PathVariable String reportId) throws IOException {
Path pdf = Paths.get("reports", reportId + ".pdf").normalize();
if (!Files.exists(pdf)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
InputStream stream = Files.newInputStream(pdf);
InputStreamResource resource = new InputStreamResource(stream);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + pdf.getFileName() + "\"")
.contentLength(Files.size(pdf))
.contentType(MediaType.APPLICATION_PDF)
.body(resource);
}

When this endpoint is called, Spring starts the response headers first, then begins reading from the stream and writing to the client. The stream stays open until the entire file is delivered or the client disconnects. There’s no need to preload anything.

What Happens in the Dispatcher Servlet

When the controller returns a response with a stream, Spring hands it off to its main request handler, the DispatcherServlet. This servlet is the center of Spring’s request processing and manages how responses are written. It decides which HttpMessageConverter should be used to handle the type returned by the controller. For a resource like an InputStreamResource, Spring picks the ResourceHttpMessageConverter. That converter starts pulling bytes from the stream and sends them out through the servlet response’s output stream. There’s no caching, no pre-copying of the full file, and no buffering unless you manually add it.

Everything happens inside the thread that handled the request, which keeps things simple unless you go out of your way to configure async behavior. The response body won’t begin until headers are finalized, which is why all headers must be written before the stream is touched. Once data starts flowing, the headers are locked in.

If the client connection drops or the stream throws an error, Spring ends the transfer, logs it, and reclaims the thread and resources. There’s nothing you usually need to handle directly in those cases.

Buffer Sizes Control Performance

When large files are sent as streams, they’re read and written in small blocks. These blocks are usually controlled by buffer sizes. While you can’t pass a buffer size directly into Spring’s response handling, you can wrap the stream yourself with something buffered. A buffered stream wraps the base input and reads larger chunks at a time, reducing the number of small read operations. This can improve transfer speed, especially when working with slower disks or remote storage backends.

Here’s a version that uses a buffered input stream around the file stream before handing it off to Spring:

@GetMapping("/archive/{name}")
public ResponseEntity<Resource> getArchive(@PathVariable String name) throws IOException {
Path zipFile = Paths.get("archives", name + ".zip").normalize();
if (!Files.exists(zipFile)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
InputStream base = Files.newInputStream(zipFile);
BufferedInputStream buffered = new BufferedInputStream(base, 16 * 1024);
InputStreamResource stream = new InputStreamResource(buffered);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + zipFile.getFileName() + "\"")
.contentLength(Files.size(zipFile))
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(stream);
}

Here the file is read in 16 KB chunks. That size usually hits a good balance between throughput and memory. You could change it if needed, but for most cases, this is enough to get good performance without overthinking it. The stream still flows directly to the client, just with fewer disk reads.

How Spring Keeps the Memory Footprint Low

When a file is streamed like this, Spring doesn’t try to buffer or cache the whole thing. It simply pulls from the input and writes to the output in small steps. As long as the data keeps coming, the output keeps moving. At no point does the entire file live in memory.

This behavior is especially important when handling large files or multiple downloads at once. It prevents one user from taking up too much memory and allows other requests to keep moving along without delay. No extra threads are spawned unless you explicitly configure that. The request thread is reused for the entire transfer, and it finishes when the file is done or the connection is lost.

Some setups use gzip or other forms of compression by default. For file downloads, that’s usually turned off. Compressing a stream adds another layer of processing and forces some buffering. That makes less sense for already compressed files like ZIP or PDF, and can get in the way of performance.

You can control this with server settings or by marking content types as excluded from compression. Spring will then send the data directly without adding overhead.

Here’s a simple case that returns binary content generated on the fly, streamed directly:

@GetMapping("/raw")
public ResponseEntity<Resource> streamRawBytes() {
byte[] data = new byte[1024 * 1024];
new Random().nextBytes(data);
ByteArrayInputStream stream = new ByteArrayInputStream(data);
InputStreamResource resource = new InputStreamResource(stream);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=random.bin")
.contentLength(data.length)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}

This one uses an in-memory stream, but the flow behaves the same. Spring still treats it as a stream, sending the content out in chunks as it’s read.

Writing From Other Sources Instead of Files

Streaming doesn’t need to come from the file system. You can send content from memory, from another service, or from output you generate during the request itself. The only requirement is that it can provide an InputStream. This works nicely for dynamic reports, live exports, or data that exists only temporarily. It avoids the need to write anything to disk, and gives the client a download right away.

Here’s a basic example of generating a CSV in memory and streaming it to the client:

@GetMapping("/export/csv")
public ResponseEntity<Resource> streamCsvExport() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
PrintWriter writer = new PrintWriter(out);
writer.println("id,name,email");
for (int i = 1; i <= 100; i++) {
writer.printf("%d,User%d,user%[email protected]%n", i, i, i);
}
writer.flush();
InputStream input = new ByteArrayInputStream(out.toByteArray());
Resource resource = new InputStreamResource(input);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"users.csv\"")
.contentLength(out.size())
.contentType(MediaType.TEXT_PLAIN)
.body(resource);
}

This lets you respond with something built on demand, without needing to manage files or cleanup steps afterward. It also keeps the response behavior consistent. From the client’s perspective, it’s just another file download.

Conclusion

File streaming in Spring Boot works because of how it hands off raw byte data through InputStream objects and writes directly to the response output. It avoids pulling the whole file into memory, and it does that by reading in small steps, flushing them to the client as it goes. The servlet layer handles that flow without anything extra needed. As long as the stream is readable and the headers are set before writing starts, Spring will move the data along without holding up the rest of the app.

  1. Spring Boot File Upload and Download Docs
  2. Spring Framework Resource Handling
  3. Java Files API (NIO)
  4. Spring Web MVC Reference

Thanks for reading! If you found this helpful, highlighting, clapping, or leaving a comment really helps me out.

Spring Boot icon by Icons8

Learn more File Downloads in Spring Boot with Byte Streams

Leave a Reply