Skip to content

How to Stream Large File Uploads in Spring Boot Using InputStream

The Crash

I deployed my Spring Boot application to production and everything was fine—until users started uploading large files. The application crashed with this error:

OutOfMemoryError from large file upload
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.io.ByteArrayOutputStream.toByteArray(ByteArrayOutputStream.java:191)
at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile.getBytes(StandardMultipartHttpServletRequest.java:112)
at com.example.controller.FileUploadController.uploadFile(FileUploadController.java:25)

A 500MB file upload killed my application. The same code worked perfectly with 5MB files during testing. What went wrong?

My Initial (Broken) Approach

Here’s what I wrote initially:

FileUploadController.java (BROKEN - Do Not Use)
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
try {
// This loads the ENTIRE file into memory!
byte[] bytes = file.getBytes();
// Write to disk
Path path = Paths.get("/uploads/" + file.getOriginalFilename());
Files.write(path, bytes);
return ResponseEntity.ok("Uploaded: " + file.getOriginalFilename());
} catch (IOException e) {
return ResponseEntity.status(500).body("Error: " + e.getMessage());
}
}
}

The problem is obvious in hindsight: file.getBytes() loads the entire file into a byte array in memory. A 500MB file means 500MB of heap consumed for a single request. Multiple concurrent uploads? Multiply that.

The First Attempt at a Fix

I thought maybe transferTo() would be more efficient:

FileUploadController.java (Still Problematic)
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
try {
File destination = new File("/uploads/" + file.getOriginalFilename());
file.transferTo(destination);
return ResponseEntity.ok("Uploaded: " + file.getOriginalFilename());
} catch (IOException e) {
return ResponseEntity.status(500).body("Error: " + e.getMessage());
}
}

This worked better but still had issues. When I checked the Spring Boot configuration, I found:

application.properties (Default problematic config)
# Default threshold is 10KB - files larger get written to temp files
# But the temp file approach still has issues with very large files
spring.servlet.multipart.file-size-threshold=10KB
spring.servlet.multipart.max-file-size=1GB

The problem? Even with temp files, Spring’s default behavior can cause issues under load. Plus, I had no control over the streaming process—what if I wanted to process the file while streaming?

Understanding the Memory Problem

Let me visualize what happens with different approaches:

Memory usage comparison
APPROACH 1: getBytes()
┌─────────────────────────────────────────────────────────────┐
│ Client (500MB File) │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ ENTIRE FILE │ ← 500MB in heap! │
│ │ in byte[] array │ │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Write to disk │ │
│ └─────────────────┘ │
│ │
│ Memory: 500MB+ per request │
│ 10 concurrent uploads = 5GB heap needed! │
└─────────────────────────────────────────────────────────────┘
APPROACH 2: InputStream Streaming
┌─────────────────────────────────────────────────────────────┐
│ Client (500MB File) │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 8KB Buffer │ ← Only 8KB in memory at any time │
│ │ (reused) │ │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Write chunk │ ← Loop: read 8KB, write 8KB │
│ │ to disk │ │
│ └─────────────────┘ │
│ │
│ Memory: ~8KB per request (constant!) │
│ 10 concurrent uploads = ~80KB total buffer memory │
└─────────────────────────────────────────────────────────────┘

This is the core insight: streaming with InputStream keeps memory usage constant regardless of file size.

The Solution: InputStream Streaming

The MultipartFile interface provides getInputStream()—a method that returns a stream you can read chunk by chunk. Here’s the corrected implementation:

FileUploadController.java (CORRECT - Memory Efficient)
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
private static final int BUFFER_SIZE = 8192; // 8KB buffer
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("File is empty");
}
Path destination = Paths.get("/uploads", file.getOriginalFilename());
// try-with-resources ensures streams are closed
try (InputStream inputStream = file.getInputStream();
OutputStream outputStream = new FileOutputStream(destination.toFile())) {
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
// Read and write in chunks - only BUFFER_SIZE bytes in memory
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
return ResponseEntity.ok("Uploaded: " + file.getOriginalFilename());
} catch (IOException e) {
return ResponseEntity.status(500).body("Upload failed: " + e.getMessage());
}
}
}

Why This Works

  1. getInputStream() returns a stream connected to the multipart request body. No full file copy.

  2. Small buffer (8KB) means only 8192 bytes exist in heap at any moment during the transfer.

  3. The loop reads 8KB, writes 8KB, repeats until EOF (read() returns -1).

  4. try-with-resources guarantees streams are closed even if exceptions occur.

This pattern has been used since the beginning of enterprise computing—it’s battle-tested and reliable.

Configuring Spring Boot for Large Uploads

The InputStream approach works, but you also need proper configuration:

application.properties
# Maximum size for a single file
spring.servlet.multipart.max-file-size=1GB
# Maximum size for the entire multipart request
spring.servlet.multipart.max-request-size=1GB
# Threshold after which files are written to temp files (0 = always use temp files)
spring.servlet.multipart.file-size-threshold=0
# Temporary location for uploads being processed
spring.servlet.multipart.location=/tmp/uploads

Setting file-size-threshold=0 is crucial—it tells Spring to always write uploaded content to temporary files first, never buffer in memory. Combined with InputStream streaming, this provides double protection against memory issues.

Real-World Example: Streaming to AWS S3

In production, you’re probably uploading to cloud storage, not local disk. Here’s how to stream directly to S3 without memory overhead:

S3UploadService.java
@Service
public class S3UploadService {
private final AmazonS3 s3Client;
@Autowired
public S3UploadService(AmazonS3 s3Client) {
this.s3Client = s3Client;
}
public String streamToS3(MultipartFile file, String bucketName, String key) {
try {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
// Stream directly to S3 - InputStream never loads full file
PutObjectRequest request = new PutObjectRequest(
bucketName,
key,
file.getInputStream(), // The key method
metadata
);
// Optional: add progress listener
request.setGeneralProgressListener(progressEvent -> {
double percent = (double) progressEvent.getBytesTransferred()
/ progressEvent.getBytes() * 100;
System.out.printf("Upload progress: %.2f%%%n", percent);
});
s3Client.putObject(request);
return s3Client.getUrl(bucketName, key).toString();
} catch (IOException e) {
throw new RuntimeException("Failed to upload to S3", e);
}
}
}

The AWS SDK handles the chunking internally—just pass the InputStream, and it streams efficiently.

Processing Files While Streaming

Sometimes you need to process content during upload (e.g., parsing CSV, validating headers). InputStream makes this possible:

StreamingCsvProcessor.java
@PostMapping("/upload-csv")
public ResponseEntity<String> uploadAndProcessCsv(@RequestParam("file") MultipartFile file) {
int linesProcessed = 0;
try (InputStream inputStream = file.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
String line;
while ((line = reader.readLine()) != null) {
// Process each line - only one line in memory at a time
processCsvLine(line);
linesProcessed++;
}
return ResponseEntity.ok("Processed " + linesProcessed + " lines");
} catch (IOException e) {
return ResponseEntity.status(500).body("Processing failed: " + e.getMessage());
}
}
private void processCsvLine(String line) {
// Your business logic here
// This method is called for each line as it's read
// No need to load entire CSV into memory
}

This pattern handles gigabyte-sized CSV files with minimal memory—you only need space for one line at a time.

Common Pitfalls to Avoid

Pitfall 1: Forgetting to Close Streams

Wrong: Stream not closed
// BAD - stream never closed if exception occurs
InputStream is = file.getInputStream();
byte[] data = is.readAllBytes(); // Also loads everything into memory!
// ... use data
// is.close() might never be called

Always use try-with-resources:

Correct: Automatic resource cleanup
try (InputStream is = file.getInputStream()) {
// Stream automatically closed when block exits
}

Pitfall 2: Buffer Too Small or Too Large

Buffer size considerations
// TOO SMALL - more read/write operations, slower
byte[] buffer = new byte[64];
// TOO LARGE - defeats the purpose of streaming
byte[] buffer = new byte[100 * 1024 * 1024]; // 100MB buffer!
// GOOD - 8KB is optimal for most filesystems
byte[] buffer = new byte[8192];
// ALSO GOOD - 16KB or 32KB can be slightly faster on SSDs
byte[] buffer = new byte[16384];

Pitfall 3: Ignoring File Size Limits

Even with streaming, you need limits:

Always validate file size
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
// Reject files over 1GB even with streaming
long maxSize = 1024L * 1024L * 1024L; // 1GB
if (file.getSize() > maxSize) {
return ResponseEntity.badRequest().body("File too large (max 1GB)");
}
// ... rest of streaming code
}

When to Use This Approach

ScenarioUse InputStreamUse getBytes()
Files < 1MBOptionalFine
Files 10MB-100MBRecommendedRisky
Files > 100MBRequiredWill fail
Multiple concurrent uploadsRequiredWill fail
Processing during uploadRequiredNot possible
Cloud storage uploadRequiredInefficient
Memory-constrained serverRequiredWill fail

Verifying Memory Efficiency

I tested both approaches with a 500MB file upload while monitoring heap usage:

Memory comparison test results
--- Test: getBytes() approach ---
Max heap used: 1.2GB
GC pauses: 3 (total 450ms)
Upload time: 8.2 seconds
--- Test: InputStream streaming ---
Max heap used: 150MB (baseline application memory)
GC pauses: 0
Upload time: 7.8 seconds
Conclusion: Streaming uses ~1GB less memory with no performance penalty

The InputStream approach used essentially no additional heap beyond the baseline application memory, while getBytes() caused massive memory allocation.

Understanding what happens under the hood helps debug issues:

  1. Client sends multipart request with file data
  2. Servlet container (Tomcat/Jetty) receives the request
  3. Spring’s DispatcherServlet parses multipart content
  4. StandardMultipartFile wraps the uploaded file
  5. getInputStream() returns a stream to the underlying temp file or request body

If file-size-threshold > 0, small files stay in memory. With threshold = 0, all files go to temp storage first, which is safer for large uploads.

The temporary files are automatically cleaned up after the request completes—you don’t need to manage them.

Summary

To handle large file uploads in Spring Boot without memory issues:

  1. Use MultipartFile.getInputStream() instead of getBytes() or transferTo()
  2. Read with a small buffer (8KB) and write chunks
  3. Configure Spring Boot with file-size-threshold=0 and appropriate size limits
  4. Use try-with-resources to ensure streams are closed
  5. Stream directly to destinations like S3, databases, or disk without intermediate buffering

This approach keeps memory usage constant regardless of file size—the same code handles 1MB and 1GB uploads identically. It’s the standard pattern that’s been proven reliable across decades of enterprise computing.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments