Introduction
A Spring Boot API that works perfectly in testing can fall over in production the moment a client sends a 50 MB JSON body, uploads a large file, or requests a report containing hundreds of thousands of rows. Large payloads don’t just slow things down — they cause OutOfMemoryError, connection resets, gateway timeouts, and cascading failures across the whole request chain.
The difficulty in production isn’t recognizing that a payload is “large” — it’s that the failure often surfaces far from its cause: a gateway returns 413, an unrelated endpoint on the same instance starts throwing OutOfMemoryError, or a mobile client silently gives up mid-upload. This guide covers why large payloads break Spring Boot applications, how to configure the framework correctly, and the architectural patterns production systems actually use to handle payload size safely at scale.
Prerequisites
- Spring Boot Fundamentals and Spring MVC basics
- Basic understanding of the JVM heap and garbage collection
- Familiarity with multipart file upload handling
Related reading:
- Spring Boot REST API Slow Response (Performance Tuning Guide)
- How to Fix Request Timeout in Spring Boot REST APIs
Learning Objectives
- Understand how Spring Boot buffers request/response bodies in memory by default.
- Configure safe payload-size limits at the application and multipart layers.
- Stream large uploads and downloads instead of buffering them fully.
- Apply pagination and compression correctly, and know what each does and doesn’t solve.
- Route very large binary payloads around the application server entirely.
Table of Contents
- Why Large Payloads Are Dangerous
- How Spring Boot Handles Bodies Internally
- Configuring Payload Limits Correctly
- Handling Large File Uploads (Multipart)
- Streaming Large Responses
- Pagination Instead of Bulk Payloads
- Compression (GZIP)
- Where Large Payloads Should Actually Flow
- Production Case Studies
- Architect’s Perspective: Trade-offs, Scale & War Story
- Common Mistakes
- Best Practices Checklist
- Troubleshooting Decision Tree
- Interview Questions
- FAQs
Why Large Payloads Are Dangerous in Production
Spring Boot, by default, reads incoming request bodies and builds outgoing response bodies entirely in memory before handing them to your controller or writing them to the socket. This is fine for typical JSON payloads, but breaks down at scale:
- Heap pressure: a 200 MB request body means at least 200 MB of heap consumed just to hold the raw bytes, plus more for deserialized objects (often 2–5x larger than the raw JSON).
- Thread starvation: a Tomcat worker thread stays blocked for the entire duration of reading/writing a large body.
- Gateway/proxy timeouts: a large payload that Spring Boot accepts may still get rejected upstream by NGINX/ALB body-size limits.
- Client-side timeouts: mobile and browser clients often abandon uploads/downloads that take too long.
┌─────────┐ large body ┌────────────┐ buffers in RAM ┌───────────┐
│ Client │──────────────▶│ API Gateway│─────────────────▶│ Tomcat │
└─────────┘ └────────────┘ │ Thread │
│ (blocked) │
└─────┬─────┘
│
┌───────▼────────┐
│ Controller │
│ (full object in │
│ heap memory) │
└────────────────┘
How Spring Boot Handles Request/Response Bodies Internally
Spring MVC (servlet stack, backed by Tomcat) uses HttpMessageConverter implementations to convert the full request InputStream into a Java object, and back into a response OutputStream. Both directions materialize the complete payload. Spring WebFlux processes the body as a Flux<DataBuffer> and can stream — but only if your code is reactive end-to-end.
Configuring Payload Limits Correctly
server:
tomcat:
max-swallow-size: 10MB
max-http-form-post-size: 10MB
max-http-request-header-size: 16KB
spring:
servlet:
multipart:
max-file-size: 25MB
max-request-size: 30MB
For non-multipart JSON bodies, reject oversized requests before Jackson buffers them:
@Component
public class PayloadSizeFilter extends OncePerRequestFilter {
private static final long MAX_BODY_BYTES = 5 * 1024 * 1024; // 5MB
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
long contentLength = request.getContentLengthLong();
if (contentLength > MAX_BODY_BYTES) {
response.sendError(HttpStatus.PAYLOAD_TOO_LARGE.value(),
"Request body exceeds maximum allowed size");
return;
}
chain.doFilter(request, response);
}
}
Handling Large File Uploads (Multipart)
Never bind an entire uploaded file to a byte[] or String. Stream it directly to disk or object storage:
@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("Empty file");
}
Path target = Paths.get("/data/uploads", file.getOriginalFilename());
file.transferTo(target);
return ResponseEntity.ok("Uploaded: " + target);
}
For very large files, production systems typically issue a pre-signed upload URL (S3, GCS) so the client uploads directly to object storage, and the API only handles metadata.
Streaming Large Responses
@GetMapping("/export/transactions")
public ResponseEntity<StreamingResponseBody> exportTransactions() {
StreamingResponseBody stream = outputStream -> {
try (Stream<Transaction> transactions = transactionRepository.streamAll()) {
transactions.forEach(txn -> {
try {
outputStream.write((txn.toCsvRow() + "\n").getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=transactions.csv")
.body(stream);
}
Combined with a streaming JPA/Hibernate query, this keeps memory usage flat regardless of result-set size.
Pagination Instead of Bulk Payloads
@GetMapping("/orders")
public Page<OrderResponse> getOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
return orderRepository.findAll(PageRequest.of(page, size))
.map(OrderResponse::from);
}
Enforce a maximum page size server-side so a client can’t request size=1000000 and reintroduce the problem pagination was meant to solve.
Compression (GZIP) for Large Payloads
server:
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/plain,text/csv
min-response-size: 2048
Compression reduces bandwidth but not server-side memory pressure — it complements streaming/pagination, it doesn’t replace them.
Architecture: Where Large Payloads Should Actually Flow
Client / Mobile
│ (small metadata request)
▼
Spring Boot API ──issues pre-signed URL──▶ Object Storage (S3 / GCS / Blob)
▲
direct upload/download
Production Case Studies
Case Study 1 – Report Export Causing Intermittent OOM Kills
Symptoms: Random OutOfMemoryError crashes, seemingly unrelated to traffic volume.
Investigation: Heap dumps showed large List<Transaction> retention correlated exactly with calls to an unpaginated CSV export endpoint used by a small number of finance users.
Root Cause: A findAll() query loading the entire transaction table into memory before writing CSV output.
Solution: Replaced with a streaming query and StreamingResponseBody, reducing peak heap usage for that endpoint from several GB to near-constant.
Case Study 2 – 413 Errors Despite Correct Spring Boot Config
Symptoms: Large file uploads failed with 413 even though max-file-size was set generously.
Investigation: The application-level config was correct, but the NGINX reverse proxy in front of it had a default client_max_body_size far smaller than the application’s limit.
Root Cause: Two independent, unaligned body-size limits at different layers.
Solution: Aligned NGINX’s client_max_body_size with the Spring Boot multipart limit and documented both together in the deployment runbook.
Architect’s Perspective: Trade-offs, Scale & War Story
Trade-off Analysis: Where Payload Handling Belongs
The core architectural decision isn’t “how do we configure Spring Boot to accept bigger payloads” — it’s “should this payload ever touch the application server at all.” Pre-signed object storage URLs decouple app-server capacity from file-transfer volume at the cost of added complexity (two round trips, client-side upload logic).
At Scale: Why Buffered I/O Doesn’t Horizontally Scale Cleanly
Memory usage per concurrent large request scales linearly with payload size × concurrency, capping how many large-payload requests a single instance can serve regardless of CPU headroom.
Payload-aware capacity model:
instances = max(rate-based, memory_headroom / (avg_payload_size × p99_concurrent_large_requests))
When NOT to Stream
Streaming adds real complexity — error handling mid-stream is harder. For payloads comfortably under a few MB with predictable, bounded size, buffered handling is simpler to build, test, and debug — streaming is a scale-driven optimization, not a default.
Observability: What to Actually Monitor
Track heap usage correlated with concurrent large-payload requests specifically, and monitor request body size distribution over time — a creeping p99 payload size often signals a client integration change before it becomes an incident.
Production War Story
A reporting endpoint originally built for internal ops use gets reused by a customer-facing dashboard — same unpaginated implementation, but now called by every logged-in user’s browser. The endpoint starts causing sporadic OOM kills under normal business-hours traffic once total user count crosses a threshold nobody had modeled. The fix added a policy: any endpoint change in expected caller (internal tool → customer-facing feature) requires an explicit payload/scale re-review.
Common Mistakes
| Mistake | Impact | Better Practice |
|---|---|---|
| Binding uploaded files to byte[] | Entire file forced into heap | Stream to disk/object storage via transferTo() |
| Unpaginated findAll() returned as JSON | Unbounded response size and memory use | Enforce pagination with a max page size |
| Raising max-file-size without a timeout/circuit-breaker strategy | Larger failures instead of fewer | Pair limit increases with streaming and monitoring |
| Assuming GZIP fixes OutOfMemoryError | Memory pressure unchanged | Use streaming/pagination for memory; GZIP for bandwidth |
| Gateway and app body-size limits not aligned | Confusing 413s from the wrong layer | Document and align limits across all layers |
Best Practices Checklist
- [ ] Explicit
max-file-size/max-request-sizefor multipart endpoints - [ ] A filter rejecting oversized bodies by
Content-Lengthbefore deserialization - [ ] Pagination enforced with a server-side maximum page size
- [ ] Large downloads served via
StreamingResponseBody+ a streaming repository query - [ ] GZIP compression enabled for text-based responses above a size threshold
- [ ] Large binary transfers routed through pre-signed object storage URLs
- [ ] Gateway/load-balancer body-size limits reviewed alongside Spring Boot limits
Troubleshooting Decision Tree
Large payload issue reported
│
├─ File upload? → check multipart limits → gateway limits → move to pre-signed storage if >50MB
├─ Large JSON response?
│ ├─ Unpaginated list? → add pagination + max page size
│ └─ Bounded but large? → enable compression + StreamingResponseBody
└─ OutOfMemoryError?
├─ On request path → add PayloadSizeFilter
└─ On response path → switch findAll() to a streaming query
Interview Questions
- Why does Spring MVC buffer the entire request body in memory by default, and how does WebFlux differ?
- How would you reject an oversized request before Jackson attempts to deserialize it?
- What’s the difference between
max-file-sizeandmax-request-size? - How does
StreamingResponseBodyreduce memory usage compared to returning aList<T>? - Why doesn’t GZIP compression solve an
OutOfMemoryErrorcaused by a large response?
FAQs
Q: What’s the maximum request body size Spring Boot supports by default?
There’s no hard framework-level cap by default — the practical limit comes from Tomcat’s form-post size settings and available heap. Always set an explicit limit.
Q: Why do I get a 413 even though my Spring Boot config allows a bigger size?
An upstream layer — NGINX, gateway, or load balancer — likely has its own smaller body-size limit rejecting the request first.
Continue Reading on SpringBootFixes
- Spring Boot REST API Slow Response (Performance Tuning Guide)
- How to Fix Request Timeout in Spring Boot REST APIs
Conclusion
Large payloads break Spring Boot applications through memory pressure, not just slowness — the fix is rarely “raise the limit.” Pagination, streaming, and routing binary transfers through object storage each address a different part of the problem, and production systems typically need all three together, aligned with matching limits at every layer between client and application.
