File Upload in Spring Boot: MultipartFile, S3, Streaming & Validation
Master Spring Boot file upload with MultipartFile, S3 integration, streaming large files, size limits, and production-ready validation.
- Use
@RequestParam MultipartFile fileor@RequestBodywithMultipartFilein a@RestControllerto accept uploads - Configure
spring.servlet.multipart.max-file-sizeandspring.servlet.multipart.max-request-sizeinapplication.properties - Stream large files with
InputStreamto avoid heap exhaustion — never load the whole file into a byte array - Validate file type by checking magic bytes, not just the extension or Content-Type header
- Use AWS SDK v2
S3AsyncClientwith multipart upload for files over 100 MB in production
Think of file upload like handing a package to a delivery service. Spring Boot is the counter — it needs to know the maximum package size it will accept, where to temporarily store it, and where to forward it. If the package is huge, you don't carry the whole thing at once; you pass it through a conveyor belt piece by piece (streaming), so you don't drop it.
At 2 AM your on-call phone rings. The service is throwing java.lang.OutOfMemoryError on every request. The root cause: a junior dev used file.getBytes() on a 500 MB video upload, loading the entire file into the JVM heap on every concurrent request. The pod crashed, and the autoscaler couldn't keep up. This is the most common file-upload mistake in Spring Boot, and it's entirely preventable.
File upload sounds simple — it's just bytes going from a browser to a server — but the production surface area is enormous. You need to think about request size limits at four layers: the client, the reverse proxy (Nginx/ALB), the Spring DispatchedServlet, and your application code. Missing any one layer causes mysterious 413 errors or silent truncation.
Multipart handling in Spring Boot changed significantly between Spring Boot 2 and 3. The underlying Tomcat, Jetty, or Undertow behaviour differs, and when you put a Kubernetes ingress in front, default body size limits of 1 MB will silently break uploads before your code even runs.
Then there's security. Accepting arbitrary files from the internet is an attack surface. Path traversal, polyglot files (a valid image that is also a ZIP bomb or a script), MIME type spoofing — each has a real CVE attached. Production file upload code must validate at the byte level, not at the filename level.
This guide covers everything from the basic MultipartFile handler through streaming with InputStream, virus scanning hooks, S3 multipart upload using AWS SDK v2, and the exact Actuator metrics you should alert on in production.
Basic MultipartFile Upload Endpoint
The simplest Spring Boot file upload endpoint uses @RequestParam MultipartFile file on a @PostMapping. This works for files that fit comfortably in memory — anything under ~50 MB on a well-tuned JVM. The critical first step is configuration: without explicit size limits, Spring Boot defaults to 1 MB for both max-file-size and max-request-size, which is useless for any real application.
Annotate your controller with @RestController and @RequestMapping. The MultipartFile parameter gives you access to getOriginalFilename(), getContentType(), getSize(), and getInputStream(). Never trust getOriginalFilename() or getContentType() — both come directly from the client and can be spoofed. A file named photo.jpg with Content-Type: image/jpeg can contain arbitrary bytes.
For small files, after validation, you can write to local disk with Files.copy(file.getInputStream(), destination, StandardCopyOption.REPLACE_EXISTING). For production systems, local disk is an antipattern — your pods are ephemeral. Write to S3, GCS, or Azure Blob Storage instead.
Always return a structured response — include the generated file ID (UUID), the storage path, the file size, and the detected MIME type. Never return the original filename in the response without sanitizing it, as it could contain path traversal sequences.
file.getSize() logging at INFO level — unexpected size spikes in logs often precede OOM incidents by minutes.Streaming Large Files to S3 with AWS SDK v2
For files over 50 MB, or when running at scale, streaming directly to S3 without intermediate buffering is the only production-viable approach. AWS SDK v2 introduced S3AsyncClient and AsyncRequestBody.fromInputStream(), which pipes the MultipartFile's InputStream directly to S3 over HTTP/2 without ever materializing the bytes on the JVM heap.
For files over 100 MB, use the S3TransferManager (built on top of S3AsyncClient) which automatically splits the upload into 8 MB chunks and uploads them in parallel via the S3 multipart upload API. This is dramatically faster on high-bandwidth connections and is resumable — if the connection drops mid-upload, only the failed parts need to be retried.
The tricky part is that MultipartFile.getInputStream() can only be read once. If you need to compute a checksum or validate the file type in a streaming fashion, you must compose the stream: wrap it in a DigestInputStream for MD5/SHA-256, then pipe it through your validator, then to S3, all in a single read pass. Apache Tika supports streaming detection with AutoDetectParser.
For very large files (video, datasets), consider presigned S3 URLs: the client uploads directly to S3, bypassing your server entirely. Your server generates the presigned URL, the client uploads, and S3 triggers an event notification to your backend via SQS. This eliminates your server from the upload path entirely, which is the correct architecture for files over 500 MB.
Always set a content MD5 or SHA-256 header on S3 puts. S3 verifies the checksum and rejects corrupted uploads. This catches network corruption that would otherwise silently store a truncated file.
Executors.newVirtualThreadPerTaskExecutor() to AsyncRequestBody.fromInputStream() to avoid blocking platform threads.File Validation: Magic Bytes, Size Limits, and Virus Scanning
Production file upload validation has three layers: size limits (enforced at the framework level before your code runs), type validation (using magic bytes, not extensions), and content scanning (ClamAV or a commercial API for virus/malware detection).
Magic byte detection with Apache Tika is the standard approach. Tika reads the first few bytes of the stream and matches them against a database of known file type signatures. A JPEG always starts with FF D8 FF, a PNG with 89 50 4E 47, a PDF with 25 50 44 46. No amount of filename manipulation can fake these bytes.
For images, additionally validate that the file can actually be decoded as an image. Use ImageI wrapped in a try-catch — if it returns null or throws, the file is corrupt or a disguised binary. For a more thorough check, use the O.read()scrimage library which also detects image bombs (tiny files that expand to gigabytes when decoded).
Virus scanning in production typically uses ClamAV via its Unix socket. Run ClamAV as a sidecar in your Kubernetes pod and connect to its socket using the clamav4j library. Scan the input stream before storing it anywhere. For high-throughput systems, send files to a scanning queue asynchronously and move them from a quarantine bucket to the public bucket only after a clean scan result.
Path traversal prevention: never use file.getOriginalFilename() as a filesystem path. Always generate a UUID-based filename server-side. If you must preserve the original name for display, store it in a database field only — never use it for filesystem operations.
ImmutableImage.loader() has a built-in dimension limit option.Global Exception Handling for Upload Errors
Upload error handling is a user experience problem as much as a technical one. By default, Spring Boot returns a 500 with a stack trace when MaxUploadSizeExceededException is thrown. Users see a cryptic error; the frontend has no structured error to parse. A @ControllerAdvice exception handler converts these to proper HTTP responses with machine-readable error bodies.
The key exceptions to handle: MaxUploadSizeExceededException (size limit hit), MissingServletRequestPartException (no file in the request), HttpMediaTypeNotSupportedException (wrong Content-Type header), MultipartException (any parsing failure), and your own custom StorageException and ValidationException.
For the 413 case, there's a subtlety: if the size limit is hit at the Tomcat level, the exception is thrown before your controller runs, so your @ControllerAdvice catches it. But if the limit is hit at the Nginx level, Nginx never forwards the request — your advice is never called. Document this distinction in your runbook.
Always include a trace-id (from MDC or Sleuth/Micrometer tracing) in error responses so that a user's support ticket can be correlated to a log line. Never include stack traces or internal paths in error responses.
file.upload.rejected with tags for rejection reason — spike alerts on this metric catch bot-driven abuse before it becomes a DDoS.Testing File Upload Endpoints
Testing file upload endpoints requires MockMultipartFile in unit tests and @SpringBootTest with TestRestTemplate or MockMvc for integration tests. The trap most developers fall into is testing only with small valid files — in production, the failures happen at the edges: empty files, files at exactly the size limit, files with spoofed MIME types, and concurrent uploads.
For integration tests, use MockMvc.perform(multipart(...)) to build the request. For testing size limit rejection, you need to mock a large MultipartFile — constructing an actual 100 MB file in a test is slow. Instead, mock MultipartFile.getSize() to return a large value and test the validation logic in isolation.
For S3 integration, use Testcontainers with the localstack image. LocalStack runs a real S3-compatible API locally. Wire your S3AsyncClient to point to http://localhost:4566 in the test profile. This tests the actual AWS SDK call, presigned URL generation, and multipart upload logic without hitting real AWS.
Always include a test that verifies the temp directory cleanup: after the upload completes (or fails), assert that no temp files remain in spring.servlet.multipart.location. Spring should clean them up, but custom code that stores the MultipartFile reference across thread boundaries can prevent cleanup and cause disk exhaustion.
Production Configuration and Observability
Complete production configuration requires tuning at four layers: Spring multipart, Tomcat connector, Kubernetes pod, and AWS infrastructure. Missing any layer creates a mismatch where upload succeeds in dev and fails silently in production.
For Tomcat, set server.tomcat.max-swallow-size=-1 to prevent Tomcat from aborting connections when the client sends more data than expected. Without this, clients uploading large files get a broken pipe even before Spring processes the request.
For observability, instrument every upload with Micrometer: a Timer for upload duration (segmented by file size bucket), a Counter for successes and failures, and a DistributionSummary for file sizes. Set alert thresholds: upload p99 > 30 seconds usually indicates an S3 or network issue; rejection rate > 5% usually indicates a bot attack or a broken client.
For the Kubernetes pod spec: set resources.limits.memory explicitly and run a load test to validate it before setting. If your upload handler is pure streaming, 512 MB per pod should handle dozens of concurrent 100 MB uploads. If anything calls getBytes(), memory is proportional to concurrent upload volume.
Finally, configure S3 bucket policies: block public access, enable versioning, enable server-side encryption with KMS, and set lifecycle rules to move objects to Glacier after 90 days. Enable S3 access logging to an audit bucket — this is required for compliance in most regulated industries.
file.upload.size.bytes p99 crossing 10 MB — most legitimate uploads are small; a spike in large file sizes is often the first sign of data exfiltration or a misconfigured integration partner.MultipartResolver Gone? Why Spring Boot Just Works (Until It Doesn't)
The old-guard tutorials love configuring StandardServletMultipartResolver beans. Don't do it. Spring Boot 3.x auto-configures multipart handling the moment it sees spring.servlet.multipart.enabled=true (it defaults to true). The real trap? Your app works fine locally but fails in production with cryptic 'Required request part is missing' errors.
The why: Spring Boot wraps DispatcherServlet with a MultipartConfigElement automatically. But if you manually define a MultipartResolver bean or mess with servlet registrations, you override this auto-configuration. Then MultipartFile parameters become null because no resolver parses the request.
Production rule: Never define @Bean MultipartResolver. Never set multipartConfigElement on DispatcherServlet manually. Let Spring Boot handle it. If you need custom settings, use application.yml properties only. That single mistake took down our staging environment for three hours.
@Bean of type MultipartResolver, Spring Boot silently disables its auto-configuration. This killed our file upload endpoint silently — no logs, no errors, just null MultipartFile parameters. Diagnose this by checking if DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME exists in the bean factory.application.yml multipart properties. When in doubt, remove every manual bean configuration and let Spring Boot's auto-configuration own it.Upload Files With Form Data: The Bad Practice That Leaks Memory
Need to send a file alongside a user ID or description? The obvious approach—sending the form data as separate request parameters—works. Until you hit a 200MB file and realize you serialized a giant JSON payload into memory alongside it.
Better approach: Use @RequestPart instead of @RequestParam. Why? @RequestPart treats each part of the multipart request independently, streaming file content while the JSON part is deserialized separately. @RequestParam with MultipartFile forces the entire request body into memory first.
Here's the pattern: Declare a DTO for non-file fields, and annotate it with @RequestPart. The file stays as MultipartFile. Spring handles the deserialization and streaming separately. This prevents OutOfMemoryErrors when users upload 4K video files with metadata.
Memory tip: Combine this with spring.servlet.multipart.file-size-threshold set to 0 to force all files straight to disk. Production servers with 512MB RAM survived a 2GB upload stress test using this pattern.
file.getBytes() on large uploads. It loads the entire file into heap memory. Always use file.getInputStream() and stream to disk or S3. Combine with file-size-threshold: 0 in config to write directly to temp files, bypassing memory entirely.@RequestPart for mixed JSON/file multipart requests. It keeps file streaming and JSON deserialization separate, preventing memory exhaustion. Pair with zero file-size-threshold for production safety.OOM Crash During Peak Traffic: The getBytes() Trap
byte[] objects each between 200–800 MB.file.getBytes() was the idiomatic approach shown in tutorials.MultipartFile.getBytes(), which copies the entire file content into a heap byte array. With 10 concurrent 400 MB uploads, the service needed 4 GB of heap just for the raw bytes, before any processing. The JVM was never configured with -Xmx appropriate for this pattern.file.getBytes() with file.getInputStream() and piped it directly to S3AsyncClient.putObject() using AsyncRequestBody.fromInputStream(). Heap usage for the same 10 concurrent uploads dropped from 4 GB to under 200 MB.- Never call
getBytes()on user-supplied files in production. - Always stream.
- Add a unit test that uploads a 1 GB file and asserts heap usage stays below 256 MB.
client_max_body_size, AWS ALB --target-group-attribute idle timeout, or Kubernetes ingress annotation nginx.ingress.kubernetes.io/proxy-body-size. Spring's spring.servlet.multipart.max-file-size is irrelevant until the request reaches Tomcat. Set the Nginx and ingress limits first, then match the Spring limit. Redeploy and retry with curl -F.spring.servlet.multipart.max-file-size or spring.servlet.multipart.max-request-size. Add a @ControllerAdvice that catches MaxUploadSizeExceededException and returns a 422 with a user-friendly message. Set spring.servlet.multipart.max-file-size=500MB and max-request-size=510MB (request is slightly larger due to multipart boundaries). Verify with curl -F 'file=@500mb.bin' http://localhost:8080/upload.spring.servlet.multipart.location) has sufficient space — if the temp write fails, the stream may be truncated silently.MultipartFile wraps a single-read stream. If your service calls getBytes() for validation and then tries to stream to S3, the stream is exhausted. Refactor to read the stream once: pipe it through a DigestInputStream (for checksum), a virus-scan filter, and then to the sink, all in a single pass./tmp, which may be a read-only layer in distroless images. Set spring.servlet.multipart.location=/uploads/tmp and mount an emptyDir volume at /uploads/tmp in your pod spec. Verify with kubectl exec -it <pod> -- ls -la /uploads/tmp.curl -v -F 'file=@testfile.bin' https://api.example.com/upload 2>&1 | grep '< HTTP'kubectl get ingress my-ingress -o yaml | grep proxy-body-sizenginx.ingress.kubernetes.io/proxy-body-size: 512m to ingress YAML and kubectl applyKey takeaways
Common mistakes to avoid
6 patternsCalling file.getBytes() in the upload handler
Trusting file.getContentType() or file.getOriginalFilename() for type validation
Not setting max-swallow-size on Tomcat
Using the original filename as a filesystem path
Not cleaning up S3 multipart upload parts on failure
Missing Nginx/ingress body size limit increase
Interview Questions on This Topic
What happens when you call MultipartFile.getBytes() on a 500 MB file with 20 concurrent requests?
Frequently Asked Questions
That's Spring Boot. Mark it forged?
9 min read · try the examples if you haven't