The 20 MB File That Changed Our Spring Boot API

Embedding file content in JSON turned a modest upload into unpredictable heap pressure—and showed us that content type is also a resource model.

A 20 MB file took down our Spring Boot receiver.

That number did not make sense at first.

The container had a memory request of 1 GB and a limit of 4 GB. Compared with those boundaries, a 20 MB upload looked too small to cause an out-of-memory failure. Increasing the memory allocation seemed like the obvious response.

But the server was never paying for only 20 MB.

We were not sending the content as a file. We were embedding it inside JSON as an array or string. That API decision changed how the receiver had to buffer, parse, represent, and transform the data.

The visible incident was an out-of-memory failure. The deeper problem was that our API contract had quietly made heap capacity part of the file-transfer protocol.

The File Size Was the Wrong Number

When we call something a 20 MB file, we usually mean its size on disk or over the wire. That number is useful for storage and network planning. It does not describe the peak working memory needed to process the request.

The Spring Boot service did not receive a simple stream of file bytes. It received a large structured document containing file content.

During request processing, that content could exist in several forms: raw request buffers, parser buffers, characters, JSON tokens, intermediate values, and the final Java object. Application logic could introduce more copies after deserialization.

The exact allocation path depends on the representation and the server stack. A JSON string and a JSON array do not have identical costs. Parser behavior, character handling, object construction, and later transformations all affect the result.

We did not establish a universal amplification ratio, and one would have been misleading anyway. The important observation was simpler: the service was not making one allocation equal to the file’s size. It was paying for the lifecycle of a large JSON document built around the file, with some representations potentially alive at the same time.

That distinction also explains why container memory numbers can create false confidence.

The 1 GB request and 4 GB limit described resource boundaries for the container. They did not reserve 4 GB of Java heap for one request. The JVM, the application, framework objects, buffers, thread stacks, caches, normal traffic, and garbage collection all operated inside the same finite envelope.

Even if the full limit had been available, treating it as temporary upload storage would not have been a safe contract.

Why More Memory Looked Reasonable

Our first tempting fix was to give the service more memory.

That response was not irrational. The immediate symptom was memory exhaustion. More capacity might have allowed the current 20 MB request to complete consistently. It could have restored service without changing clients or redesigning the endpoint.

But it would not have changed the relationship that caused the failure.

As JSON payloads grew, the receiver would still need more working memory. As requests overlapped, their live representations would compete for the same heap. A larger container would move the point of failure without creating a deliberate boundary.

The endpoint would remain safe only while file size, request concurrency, normal application load, and current runtime behavior happened to fit together.

That is not a capacity model. It is a dependency on favorable conditions.

The question therefore changed. Instead of asking how much memory would make this upload survive, we asked why receiving a file required the application to materialize it as structured data at all.

The API Contract Was Driving the Allocation Pattern

JSON had been attractive for understandable reasons. It gave the request one content type, one shape, and one object to deserialize. For small payloads, that simplicity often outweighs the resource cost.

A file changes the balance.

Once file content becomes a large string or array inside JSON, the JSON parser must participate in transporting it. The content is no longer just bytes moving through a file-oriented path. It becomes part of an object model that the receiver must interpret and construct.

That makes memory behavior a consequence of API design rather than a private implementation detail.

The failure was not evidence that JSON is inherently unsuitable for every upload. Nor did it prove that a particular file size always produces a particular heap multiplier. It showed that this contract made memory consumption grow with a large structured representation of the file, while leaving the safe operating boundary implicit.

Concurrency made that model more dangerous.

One request might fit. Several overlapping requests might not. Each arrives while the service is also doing its normal work. The result depends not only on the file size but on how many representations remain live, how long processing takes, what else occupies memory, and when objects become reclaimable.

A successful single-upload test therefore says little about the endpoint’s behavior under overlapping requests. It proves that one point in the operating space survived. It does not define the space.

We Started Treating the File as a File

We changed the request to multipart/form-data.

The visible code change was the content type. The important change was the resource model behind it.

Multipart let the receiving side handle the upload as a file part rather than as one field in a large JSON document. With the upload threshold and storage behavior configured appropriately, larger parts could be spooled outside the heap instead of remaining wholly represented there as JSON-derived application data.

That change removed the out-of-memory failure we had been seeing.

The outcome matters, but so does the boundary of the claim. It does not mean multipart eliminated memory use. It means we stopped forcing the file through the same materialization path that the JSON contract required.

The file-oriented path also had to remain intact after the framework received the request.

Multipart is not automatically memory-safe. Application code can accept a file part and immediately read the entire content into a byte array or string. A downstream component can do the same. At that point, the endpoint has changed its HTTP format while recreating much of the original heap exposure.

Our improvement came from preserving the file-oriented handling, not merely from changing the Content-Type header.

That is the mechanism that connects the fix to the incident: larger file content no longer had to live as a field inside a deserialized JSON object, and appropriate buffering behavior allowed that content to be handled outside the heap.

An Accidental Limit Became an API Decision

The incident exposed another design problem: the endpoint had no meaningful, explicit upload limit.

It had a practical limit, of course. Every system does. But that limit was whatever combination of file size, JSON representation, parser behavior, current workload, and available memory happened to survive.

Clients could not reason about that boundary. Neither could we.

An endpoint that works for one upload and fails for another has still enforced a limit. It has simply enforced it through resource exhaustion instead of through its contract.

Changing the upload path forced us to define the maximum file size the endpoint should accept. That changed the question from:

How large a file can this deployment probably handle?

to:

What file size does this API support?

The first answer varies with deployment details and traffic. The second is a design decision that clients can understand and the server can enforce deliberately.

This was one of the most valuable outcomes of the incident. A vague property of the environment became an explicit boundary. Oversized requests could be rejected because they violated the contract, not because they happened to exhaust the process.

The distinction is operationally important. A limit expressed only by failure is discovered at the worst possible time and under the least controlled conditions.

The Pressure Moved Somewhere More Appropriate

Multipart did not make the upload free.

When larger parts are spooled to temporary storage, that storage becomes a capacity concern. Concurrent uploads can create disk-space and I/O pressure. Temporary data must be cleaned up reliably. Timeouts must account for supported upload sizes and expected transfer rates. Downstream processing must avoid pulling the entire file back into heap.

We exchanged one resource profile for another.

That was the right trade-off for this endpoint because the workload was a file. File-oriented buffering and storage matched the nature of the data better than materializing it inside a large JSON object.

But moving pressure is not the same as removing it.

The new design still requires explicit limits and monitoring. Temporary capacity must account for overlapping uploads, not just one maximum-size file. Cleanup failures can turn short-lived data into persistent disk consumption. Slow processing can extend the lifetime of staged content. A later transformation can undo the benefit if it loads the complete file into memory.

This is why “use multipart” is too shallow as an engineering lesson. The useful decision is to choose where the bytes wait, how they move through the system, and which bounded resource absorbs concurrency.

For this endpoint, heap was the wrong place to use as accidental file storage. Moving the content to a file-oriented path made the cost easier to bound, but it created responsibilities around storage, I/O, cleanup, and downstream behavior.

What the Incident Did—and Did Not—Prove

The reported outcome is specific: after changing the request to multipart and preserving file-oriented handling, the out-of-memory failure we had observed was removed.

We do not have a published before-and-after heap profile, concurrency limit, latency result, or storage measurement. It would be wrong to turn the incident into claims about a universal memory multiplier or a guaranteed performance improvement.

What the evidence does support is the architectural connection.

The JSON contract encouraged the receiver to materialize file content as application data. That created memory pressure beyond the file’s nominal size and made the practical limit dependent on runtime conditions. Multipart allowed the receiver to treat the content as a file part and, with appropriate handling, spool larger content outside the heap.

The observed failure disappeared after that change. The remaining work is to manage the resource pressure where it moved.

Lessons Learned


  • A file’s disk size is not its processing footprint. The useful capacity question is which representations coexist during parsing and transformation, especially when requests overlap.



  • An API format defines a resource model. Embedding file content in JSON asks the parser and object model to participate in file transfer. That is an allocation decision, not just a preference about request syntax.



  • More memory can postpone an unbounded failure. It may be a valid short-term mitigation, but it does not create a supported file-size boundary or control the effect of concurrency.



  • Files should remain file-oriented across the processing path. Multipart helps only while controllers and downstream components avoid rematerializing the entire upload in heap.



  • Capacity limits belong in the contract. A deliberate maximum lets the server reject unsupported requests predictably instead of discovering its boundary through memory exhaustion.


The Content Type Was Only the Visible Change

A 20 MB file looked harmless beside a 4 GB container limit because we compared two numbers that described different things.

One described the file at rest. The other described a boundary shared by an entire running process. Between them sat the real problem: a JSON contract that turned file transfer into structured-data materialization.

Changing to multipart removed the failure we had observed because it let us preserve a file-oriented path and move large-part buffering outside the heap. It also forced us to make the endpoint’s maximum upload size explicit.

The trade-off did not disappear. It moved to temporary storage, I/O, cleanup, timeouts, and downstream processing.

That is the engineering judgment worth carrying into the next API review. Content type is not only about how clients encode a request. It decides where the system pays for the data—and whether that cost is visible and controllable before production reveals the limit.