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.
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:
java -Xmx8g -Xms8g -jar myapp.jarThis 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:
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:
Class | Instances | Size----------------------------------------------------------byte[] | 847 | 2.1 GBorg.apache.tomcat.util.buf.ByteChunk | 23 | 1.8 GBjava.lang.String | 152,847 | 12.3 MBThe 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:
@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:
file.getInputStream()returns a stream, not the full bytes- The 8KB buffer means only 8KB is in memory at any time
- We read a chunk, write a chunk, repeat—constant memory usage
- 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:
@Servicepublic 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:
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: 100MBKey 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:
@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:
@Servicepublic 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
@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
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
| Approach | Memory Usage | Concurrent Uploads | Best For |
|---|---|---|---|
getBytes() | Full file size | Fails under load | Small files only |
| Streaming | Constant (8KB buffer) | Scales linearly | All file sizes |
| Async + Streaming | Constant | Highest throughput | High-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:
- Never use
MultipartFile.getBytes()for large files - Stream with
getInputStream()and fixed-size buffers (8KB-64KB) - Configure
file-size-thresholdto use disk for larger files - Stream directly to cloud storage (S3, GCS) to avoid local disk entirely
- 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:
- 👨💻 Why Synchronous APIs were killing my Spring Boot Backend
- 👨💻 Spring Framework MultipartFile Documentation
- 👨💻 AWS SDK for Java S3 Upload
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments