Skip to content

How to Prevent JVM OutOfMemoryError When Handling Large File Uploads in Spring Boot

The Crash I Didn’t See Coming

My Spring Boot application crashed with OutOfMemoryError during a file upload demo. I had 4GB of heap configured, but a single 500MB file upload brought the entire JVM down. Worse, it triggered a death spiral that took out the whole service.

Terminal output
Exception in thread "http-nio-8080-exec-5" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.StringBuilder.append(StringBuilder.java:213)
...

The worst part? I thought I was doing everything right. I had configured max-file-size limits. I was using Spring’s MultipartFile API. The code worked perfectly in development with small test files.

But under production load with real user uploads, the JVM died a slow, painful death.

What I Tried First (And Why It Failed)

Attempt 1: Increase Heap Size

My first instinct was to throw more memory at the problem:

JVM arguments
java -Xmx8g -Xms8g -jar myapp.jar

This worked briefly, but two concurrent 2GB uploads still crashed the application. Plus, my infrastructure costs doubled. This wasn’t a solution—it was a bandage that made the wound fester.

Attempt 2: Limit Concurrent Uploads

I added a semaphore to limit concurrent uploads:

UploadController.java
private final Semaphore uploadSemaphore = new Semaphore(5);
@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file)
throws InterruptedException {
uploadSemaphore.acquire();
try {
byte[] bytes = file.getBytes(); // Still loading entire file into memory!
// process bytes...
return ResponseEntity.ok("Upload successful");
} finally {
uploadSemaphore.release();
}
}

This helped stability, but users complained about long wait times. And if five users uploaded 1GB files simultaneously, I still needed 5GB of heap just for uploads. The math didn’t work.

The Root Cause Discovery

I profiled the application with VisualVM and saw the problem immediately. Each MultipartFile.getBytes() call loaded the entire file into heap memory:

VisualVM heap dump analysis
Class | Instances | Size
----------------------------------------------------------
byte[] | 847 | 2.1 GB
org.apache.tomcat.util.buf.ByteChunk | 23 | 1.8 GB
java.lang.String | 152,847 | 12.3 MB

The byte[] arrays were my uploaded files, sitting in memory, waiting to be garbage collected. But under concurrent load, GC couldn’t keep up. The JVM spent more time garbage collecting than running my code—a classic “death spiral.”

The Solution: Stop Loading Files Into Memory

The key insight came from a Reddit thread where someone described exactly this problem:

“Use an InputStream with potentially huge uploads. Only read as much into memory as you need to save it somewhere else.”

This is the streaming approach. Instead of loading the entire file into a byte[] array, I read it in small chunks and write those chunks directly to their destination.

The Streaming Pattern

Here’s the corrected code:

UploadController.java
@PostMapping("/upload/streaming")
public ResponseEntity<String> uploadStreaming(
@RequestParam("file") MultipartFile file) throws IOException {
Path outputPath = Paths.get("/uploads", file.getOriginalFilename());
// Fixed-size buffer: only 8KB in memory at any time
try (InputStream inputStream = file.getInputStream();
OutputStream outputStream = Files.newOutputStream(outputPath)) {
byte[] buffer = new byte[8192]; // 8KB chunks
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
return ResponseEntity.ok("File uploaded successfully");
}

Why this works:

  1. file.getInputStream() returns a stream, not the full bytes
  2. The 8KB buffer means only 8KB is in memory at any time
  3. We read a chunk, write a chunk, repeat—constant memory usage
  4. The file size doesn’t matter: 10MB, 10GB, same 8KB memory footprint

I tested this with a 5GB file. Memory usage stayed flat at ~200MB total heap throughout the upload. No more OutOfMemoryError.

Real-World Implementation: Uploading to Cloud Storage

In production, I don’t write files to local disk. I stream them directly to S3:

S3UploadService.java
@Service
public class S3UploadService {
private final S3Client s3Client;
private final String bucketName;
public String uploadToS3(MultipartFile file, String key) throws IOException {
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(file.getContentType())
.contentLength(file.getSize())
.build();
// Stream directly to S3 without loading into memory
s3Client.putObject(request,
RequestBody.fromInputStream(
file.getInputStream(),
file.getSize()
)
);
return key;
}
}

The AWS SDK handles the streaming internally. I pass the InputStream directly to RequestBody.fromInputStream(), and the SDK reads and uploads in chunks. My application’s memory footprint stays constant.

Spring Boot Configuration for Safe Uploads

Streaming alone isn’t enough. You need to configure Spring Boot properly:

application.yml
spring:
servlet:
multipart:
enabled: true
max-file-size: 100MB # Reject files larger than this
max-request-size: 100MB # Total request size limit
file-size-threshold: 2KB # Files > 2KB go to temp disk, not memory
server:
tomcat:
max-swallow-size: -1 # Don't silently drop large uploads
max-http-form-post-size: 100MB

Key settings explained:

  • file-size-threshold: 2KB: Files larger than 2KB are written to a temporary file on disk instead of memory. This is your safety net.
  • max-file-size: 100MB: Hard limit. Requests exceeding this return 413 Payload Too Large.
  • max-swallow-size: -1: Tomcat shouldn’t silently swallow data; let me handle errors explicitly.

With this configuration, even if my streaming code has a bug, Spring Boot won’t load massive files into memory by default.

Adding Progress Tracking

Users want to see upload progress. With streaming, this is straightforward:

ProgressTrackingUpload.java
@PostMapping("/upload/progress")
public ResponseEntity<String> uploadWithProgress(
@RequestParam("file") MultipartFile file) throws IOException {
long totalBytes = file.getSize();
long processedBytes = 0;
byte[] buffer = new byte[8192];
Path tempPath = Files.createTempFile("upload-", ".tmp");
try (InputStream in = file.getInputStream();
OutputStream out = Files.newOutputStream(tempPath)) {
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
processedBytes += bytesRead;
// Calculate and log progress
int progress = (int) ((processedBytes * 100) / totalBytes);
log.info("Upload progress: {}% ({} of {} bytes)",
progress, processedBytes, totalBytes);
// For real-time updates, use WebSockets or SSE
// to push progress to connected clients
}
}
return ResponseEntity.ok("Upload complete: " + tempPath);
}

For production use, I send progress updates via WebSocket to the connected client. This provides real-time feedback without blocking the upload.

Async Processing for High Throughput

When handling many uploads, I offload processing to background threads:

AsyncUploadService.java
@Service
public class AsyncUploadService {
private final TaskExecutor taskExecutor;
private final FileProcessor fileProcessor;
@Async
public CompletableFuture<String> processAsync(MultipartFile file) {
try {
Path tempFile = Files.createTempFile("upload-", ".tmp");
// Stream to temp file
try (InputStream in = file.getInputStream()) {
Files.copy(in, tempFile, StandardCopyOption.REPLACE_EXISTING);
}
// Process in background
String result = fileProcessor.process(tempFile);
// Clean up
Files.deleteIfExists(tempFile);
return CompletableFuture.completedFuture(result);
} catch (IOException e) {
return CompletableFuture.failedFuture(e);
}
}
}

The controller returns immediately after receiving the file, and processing happens asynchronously. This keeps the API responsive under load.

Common Mistakes to Avoid

Mistake 1: Using getBytes() for Large Files

Bad: Loads entire file into memory
@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) {
byte[] bytes = file.getBytes(); // DON'T DO THIS
// process bytes...
}

This loads the entire file into heap memory. For a 1GB file, that’s 1GB of heap consumed. Under concurrent load, your JVM dies.

Mistake 2: Copying to ByteArrayOutputStream

Bad: Defeats the purpose of streaming
ByteArrayOutputStream baos = new ByteArrayOutputStream();
file.getInputStream().transferTo(baos); // Still loads everything into memory!

ByteArrayOutputStream grows to hold all data. You’ve defeated the streaming.

Mistake 3: Ignoring file-size-threshold

Without file-size-threshold configuration, Spring Boot may buffer small files in memory anyway. Set it to a low value (1KB-2KB) so even small files go to disk if you’re not streaming.

How This Changed My Architecture

After fixing the upload issue, I realized this pattern applies everywhere:

  • Database streaming: Use cursor-based fetching for large result sets, not findAll()
  • API responses: Stream large JSON responses instead of building them in memory
  • File processing: Process files line-by-line instead of loading them entirely
  • Image processing: Use streaming image libraries, not ImageIO.read() for large images

The principle is universal: process data as a stream, not as a whole.

Summary

ApproachMemory UsageConcurrent UploadsBest For
getBytes()Full file sizeFails under loadSmall files only
StreamingConstant (8KB buffer)Scales linearlyAll file sizes
Async + StreamingConstantHighest throughputHigh-volume APIs

The pattern I implemented—streaming with fixed-size buffers—has been used at three separate companies I’ve worked with. It’s become my standard answer whenever someone asks about file upload memory issues:

“Use an InputStream. Read chunks, write chunks. Never let the full file touch your heap.”

Key takeaways:

  1. Never use MultipartFile.getBytes() for large files
  2. Stream with getInputStream() and fixed-size buffers (8KB-64KB)
  3. Configure file-size-threshold to use disk for larger files
  4. Stream directly to cloud storage (S3, GCS) to avoid local disk entirely
  5. Consider async processing for improved API responsiveness

This is a pattern that, once learned, pays dividends throughout your career. It’s not about Spring Boot specifically—it’s about understanding that memory is finite and data can be processed incrementally.

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