Revision note (2026-09-15). The earlier version of this article said Spring MVC multipart uploads buffer "entire file content in memory" with a risk of OutOfMemory, that streaming in MVC needs "reactive libraries integration", that WebFlux size limits must be "managed logically" because spring.servlet.multipart.* does not apply, and it ended with a "complete" WebFlux controller whose per-buffer DataBufferUtils.write(..., CREATE, APPEND) fails on the first buffer with UnsupportedOperationException: APPEND not allowed. Its size check inside filePart.content() also runs only after the whole file is already on disk. This version replaces those claims with measurements from a lab with a WebFlux and an MVC application, 300 MB uploads, SHA-256 checks, and heap caps.
Method
- Spring Boot 3.5.16, Spring Framework 6.2.19, Java 17 (Amazon Corretto 17.0.14), Gradle 8.8; macOS on an Apple M3 Pro; uploads over loopback.
- Tests run server and client in one JVM with
-Xmx128m; the client streams a generated 300 MB body (no 300 MB array on either side) and hashes what it sends. Every successful upload is compared by SHA-256 with the file on disk. curlsessions run againstbootRunwith-Xmx64m. A/memendpoint reports the sum ofMemoryPoolMXBeanpeak usage over the heap pools; that is an upper bound on live data since JVM start, not a measurement of the upload alone.- In WebFlux, a decorated
HttpHandlercounts request-body bytes as Reactor Netty delivers them, and each handler records the count at the moment its code first runs in a response header,X-Received-At-Handler. This is the key instrument: it shows whether the handler runs while the upload is in flight or after it is over.
WebFlux: four ways to receive the same 300 MB
The lab controller exposes four endpoints. The table is the summary; the sections after it show the evidence.
| Endpoint | Signature | Result for 300 MB | Handler first ran after |
|---|---|---|---|
/upload/article | @RequestPart Mono<FilePart>, per-buffer DataBufferUtils.write(..., CREATE, APPEND) | HTTP 500, APPEND not allowed | 314,572,973 bytes (everything) |
/upload/transfer | @RequestPart Mono<FilePart> + transferTo | HTTP 200, hash matches | 314,572,974 bytes (everything) |
/upload/events | @RequestBody Flux<PartEvent> | HTTP 200, hash matches | 16,525 bytes |
/upload/raw | @RequestBody Flux<DataBuffer> (octet-stream) | HTTP 200, hash matches | 8,192 bytes |
@RequestPart Mono<FilePart> means "after the upload"
DefaultPartHttpMessageReader writes a file part to disk (above spring.webflux.multipart.max-in-memory-size, 256 KB by default) and emits the FilePart when the part is complete. So the handler runs when the body has been fully read; for the 300 MB file the byte counter stood at 314,572,974 (the file plus multipart framing). filePart.transferTo(path) then moves the temp file:
@PostMapping(value = "/transfer", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Mono<UploadResult> transferTo(@RequestPart("file") Mono<FilePart> filePartMono, ServerWebExchange exchange) {
return filePartMono.flatMap(filePart -> {
Path destination = uploadDir.resolve("transfer-" + filePart.filename()).normalize().toAbsolutePath();
return filePart.transferTo(destination)
.then(Mono.fromCallable(() -> result(destination)));
});
}
This is fine for memory (300 MB went through under a 64 MB heap; peak heap 49 MB) and it is simple. What it cannot do is reject an oversized upload early. A running counter in filePart.content() sees the bytes only after they are all on disk; the "abort at 50 MB" check in the earlier version would run after the client had sent all 300 MB. If you use FilePart, set the limit where it is enforced during the read: spring.webflux.multipart.max-disk-usage-per-part (default -1, unlimited), plus max-parts and max-in-memory-size. Those properties exist precisely because spring.servlet.multipart.* does not apply to WebFlux.
The per-buffer APPEND write cannot work
The earlier "complete" controller wrote every buffer with its own DataBufferUtils.write(Mono.just(dataBuffer), path, CREATE, APPEND). That overload opens an AsynchronousFileChannel, and AsynchronousFileChannel.open refuses StandardOpenOption.APPEND:
java.lang.UnsupportedOperationException: APPEND not allowed
at java.base/sun.nio.fs.UnixChannelFactory.newAsynchronousFileChannel(UnixChannelFactory.java:167)
at java.base/java.nio.channels.AsynchronousFileChannel.open(AsynchronousFileChannel.java:259)
at org.springframework.core.io.buffer.DataBufferUtils.lambda$write$11(DataBufferUtils.java:367)
The lab keeps that endpoint as written so the failure is reproducible: HTTP 500 for every upload, 20 MB or 300 MB. The earlier version's onErrorResume would have turned it into HTTP 400 "Upload failed: APPEND not allowed", which is how a broken server ends up blamed on the client.
Streaming for real: Flux<PartEvent> or a raw body
Spring Framework 6 has a streaming multipart API. @RequestBody Flux<PartEvent> delivers FormPartEvent and FilePartEvent objects as the body arrives; the last event of each part is flagged isLast(). The handler ran after 16,525 bytes here. The lab writes the first file part through one channel and enforces the limit on the way:
@PostMapping(value = "/events", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Mono<UploadResult> partEvents(@RequestBody Flux<PartEvent> events) {
return events
.windowUntil(PartEvent::isLast)
.flatMap(window -> window.switchOnFirst((signal, partEvents) -> {
if (!signal.hasValue() || !(signal.get() instanceof FilePartEvent first)) {
return partEvents.map(PartEvent::content).map(DataBufferUtils::release).then(Mono.empty());
}
Path destination = uploadDir.resolve("events-" + first.filename()).normalize().toAbsolutePath();
return writeBounded(partEvents.map(PartEvent::content), destination)
.then(Mono.fromCallable(() -> result(destination)));
}), 1)
.next();
}
private Mono<Void> writeBounded(Flux<DataBuffer> content, Path destination) {
AtomicLong size = new AtomicLong();
Flux<DataBuffer> bounded = content.doOnNext(b -> {
if (size.addAndGet(b.readableByteCount()) > maxBytes) {
DataBufferUtils.release(b);
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "File exceeds maximum allowed size");
}
});
return Mono.using(
() -> AsynchronousFileChannel.open(destination, CREATE, WRITE, TRUNCATE_EXISTING),
channel -> DataBufferUtils.write(bounded, channel).map(DataBufferUtils::release).then(),
channel -> closeQuietly(channel));
}
One channel for the whole stream, opened without APPEND, and DataBufferUtils.write(Publisher, AsynchronousFileChannel) keeps the order. If the endpoint accepts exactly one file and the client controls the request, skip multipart entirely and take the raw body as Flux<DataBuffer> with Content-Type: application/octet-stream; the handler ran after 8,192 bytes in that variant.
Against a 50 MB limit, the two streaming endpoints answered while the client was still sending:
$ curl -s -w "nHTTP %{http_code} %{time_total}sn" -F "[email protected]" localhost:8094/upload/events
{"timestamp":"2026-09-15T04:58:48.533+00:00","path":"/upload/events","status":413,"error":"Payload Too Large","requestId":"f8406c08-4"}
HTTP 413 0.324160s
$ curl -s -w "nHTTP %{http_code} %{time_total}sn" -H "Content-Type: application/octet-stream" --data-binary @big.bin localhost:8094/upload/raw
{"timestamp":"2026-09-15T04:58:48.827+00:00","path":"/upload/raw","status":413,"error":"Payload Too Large","requestId":"fc3784a6-5"}
HTTP 413 0.103818s
A 300 MB transfer took about 3.3 s on this machine; 0.3 s and 0.1 s mean the rejection happened at the limit, not at the end. With the limit raised, both endpoints took the full 300 MB with matching hashes and /mem reported a peak heap of 48 MB under the 64 MB cap.
A rejected streaming upload leaves a partial file (52,428,800 bytes in the lab); delete it in an error path if that matters to you. The lab does not.
Spring MVC: the part is on disk before your method runs
The earlier version's central claim was that MVC buffers the whole file in memory. It does not. Tomcat writes each part to spring.servlet.multipart.location as soon as it exceeds spring.servlet.multipart.file-size-threshold, and that threshold defaults to 0. The MVC lab handler lists that directory at entry:
$ curl -s -D - -F "[email protected]" localhost:8095/upload/multipart
HTTP/1.1 200
X-Temp-Files: upload_d7d0b461_db54_42a5_86e5_8802d7628158_00000000.tmp:314572800
{"file":"multipart-big-mvc.bin","bytes":314572800,"sha256":"156a476de5a222aa82cb73c8d59d81747702c7ab18aec6e090180b412af1ee99","elapsedMs":2045}
A 314,572,800-byte temp file already exists when @RequestParam MultipartFile file is bound; file.transferTo(path) moves it. The server ran with -Xmx64m and reported a peak heap of 51 MB. The consequence is the same as for FilePart: a size check on file.getSize() runs after the upload; enforce limits during the read with spring.servlet.multipart.max-file-size and max-request-size.
Streaming in MVC needs no reactive library. A raw body and a loop:
@PostMapping(value = "/stream", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public UploadResult stream(HttpServletRequest request) throws IOException {
Path destination = uploadDir.resolve("stream-" + safeName(request.getHeader("X-File-Name")));
long total = 0;
byte[] buffer = new byte[64 * 1024];
try (InputStream in = request.getInputStream(); OutputStream out = Files.newOutputStream(destination)) {
int n;
while ((n = in.read(buffer)) > 0) {
total += n;
if (total > maxBytes) {
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "File exceeds maximum allowed size");
}
out.write(buffer, 0, n);
}
}
return new UploadResult(destination.getFileName().toString(), total, sha256(destination));
}
300 MB, 64 KB at a time, hash matches, same 51 MB peak heap. One MVC-specific behavior showed up when this endpoint rejected a 300 MB upload at 50 MB: the handler returns without reading the rest, Tomcat swallows at most server.tomcat.max-swallow-size (2 MiB by default) of the remaining body and then closes the connection, and the client, still sending, got java.io.IOException: chunked transfer encoding, state: READING_LENGTH instead of the 413. Tomcat documents that without swallowing "the client is unlikely to see the response"; max-swallow-size=-1 makes it read everything, at the price of receiving the whole body. That setting was not run in the lab.
Choosing
- Files up to what your disk and temp directory tolerate, no need to reject early:
FilePart.transferTo(WebFlux) orMultipartFile.transferTo(MVC). Both are disk-backed; neither holds the file in the heap. - Reject early, process chunks as they arrive, or forward to object storage without a local copy:
Flux<PartEvent>in WebFlux, or a rawInputStreamloop in MVC. Multipart is only needed when browsers or form fields are involved. - Never write a stream with a new
AsynchronousFileChannelper buffer, and never withAPPENDon that channel.
What this does not cover
- Content-type or magic-byte validation, virus scanning, object storage clients.
- Concurrent uploads; every number is one upload at a time.
- Reverse proxies. NGINX and similar buffer request bodies by default; nothing here sat behind one.
- Cleanup of partial files after a rejected streaming upload.
- Whether a
flatMapwith per-buffer writes would reorder chunks. The earlier version's code never gets that far, becauseAPPENDis refused on the first buffer.
Reproduce it
gradle test --no-daemon # 13 tests, test JVM at -Xmx128m
gradle :webflux:bootRun --no-daemon --args='--demo.max-bytes=1073741824' # port 8094, -Xmx64m
head -c 314572800 /dev/urandom > big.bin
curl -s -w "nHTTP %{http_code} %{time_total}sn" -F "[email protected]" localhost:8094/upload/events
curl -s localhost:8094/mem
Sources
- Spring Framework reference: WebFlux multipart (codecs, limits)
- Spring Framework reference: WebFlux controllers, multipart forms and PartEvent
- PartEvent javadoc
- DefaultPartHttpMessageReader javadoc
- DataBufferUtils javadoc
- AsynchronousFileChannel javadoc (Java 17)
- Spring Boot common application properties (spring.webflux.multipart.*, spring.servlet.multipart.*, server.tomcat.max-swallow-size)
- Apache Tomcat 10.1 HTTP connector: maxSwallowSize
