Debugging Multipart Bodies with curl and DevTools
Reproduce the browser’s request with curl -F 'field=value' -F 'file=@photo.jpg;type=image/jpeg', add --trace-ascii - to print the exact bytes curl sends including the boundary lines, compare them with what DevTools shows under Network → Payload (view source), and check the three things that break most multipart uploads: a Content-Type header without the boundary parameter, a hand-set header that overrides the browser’s, and part names that do not match what the server expects.
Multipart bugs are frustrating because both sides look right in isolation: the form has a file, the server has a handler, and the result is 400 Bad Request, Unexpected end of form or a req.file that is simply undefined. The body in between is where the mistake is, and you cannot fix what you have not seen. This page is part of multipart form data explained in upload fundamentals and browser APIs. The format itself is walked through in implementing multipart/form-data in vanilla JavaScript; this page is about looking at it when something goes wrong.
When to use this approach
- A file upload fails with a 400, an empty file field, or a parser error, and you need to know whether the client or the server is at fault.
- You are integrating with an API that accepts multipart and want to reproduce its documented example exactly.
- Uploads work from one client (Postman, the browser) and fail from another (a mobile app, a backend service).
Prerequisites
curl7.80 or newer (curl --version), which every current OS ships.- Browser DevTools — Chrome, Edge or Firefox all show request payloads.
- Optionally, a local echo server to see what arrives. The Node one below needs nothing installed.
- The server’s expected field names — the most common mismatch is
fileversusuploadversusfiles[].
Anatomy of the bytes on the wire
Implementation
Start with a local echo server that prints exactly what it receives — headers and the first bytes of every part — so you can point any client at it.
// echo.ts — node --experimental-strip-types echo.ts, then POST to http://localhost:8787
import { createServer } from "node:http";
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
const ct = req.headers["content-type"] ?? "(none)";
const boundary = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(ct)?.slice(1).find(Boolean);
console.log(`\n${req.method} ${req.url}\ncontent-type: ${ct}\ncontent-length: ${req.headers["content-length"]} (received ${body.length})`);
if (!boundary) {
console.log("!! no boundary parameter — a multipart parser cannot split this body");
} else {
const parts = body.toString("latin1").split(`--${boundary}`);
parts.slice(1).forEach((p, i) => {
if (p.startsWith("--")) { console.log(`[end delimiter found after part ${i}]`); return; }
const [head, ...rest] = p.split("\r\n\r\n");
const payload = rest.join("\r\n\r\n");
console.log(`part ${i}:${head.replace(/\r\n/g, "\n ")}\n -> ${payload.length - 2} bytes`);
});
if (!body.toString("latin1").includes(`--${boundary}--`)) console.log("!! no closing delimiter — body truncated?");
}
res.writeHead(200, { "Content-Type": "text/plain" }).end("ok\n");
});
}).listen(8787, () => console.log("echo on :8787"));
Then send the same form three ways and compare what the echo server prints.
# 1. curl builds a correct multipart body; -F with @ attaches a file, ;type= sets its part type.
curl -sS http://localhost:8787/upload \
-F 'title=Holiday' \
-F 'photo=@IMG_1.jpg;type=image/jpeg' \
--trace-ascii - | sed -n '/=> Send header/,/=> Send data/p' | head -20
# 2. Exactly the bytes curl sent, body included (binary shown as dots):
curl -sS http://localhost:8787/upload -F 'title=Holiday' -F 'photo=@IMG_1.jpg' \
--trace-ascii trace.txt -o /dev/null && grep -n -- '--------' trace.txt | head
# 3. The classic mistake, reproduced: forcing Content-Type without a boundary.
curl -sS http://localhost:8787/upload -H 'Content-Type: multipart/form-data' \
-F 'title=Holiday' -F 'photo=@IMG_1.jpg'
And the browser-side equivalents of the right and wrong ways to send it:
const form = new FormData();
form.append("title", "Holiday");
form.append("photo", file, file.name); // third argument sets filename=
// Correct: let fetch set Content-Type, including the boundary it generated.
await fetch("http://localhost:8787/upload", { method: "POST", body: form });
// Broken: a hand-set header replaces fetch's and drops the boundary parameter.
await fetch("http://localhost:8787/upload", {
method: "POST",
body: form,
headers: { "Content-Type": "multipart/form-data" }, // the echo server prints "!! no boundary"
});
Line-by-line on what to look for
boundary=in the request header. The body is unparseable without it. If it is missing, something in your code setContent-Typeby hand — often an HTTP client wrapper with a default JSON header, or a well-meaningheaders: { "Content-Type": "multipart/form-data" }.content-lengthversus bytes received. A mismatch means the body was cut off — a proxy limit, a client timeout, a dropped connection — and parsers fail with “Unexpected end of form”. The server-side limits are covered in raising nginx and Cloudflare upload size limits.- The
nameof each part. Server frameworks look fields up by name:multer.single("photo")ignores a part namedfile. The echo output shows the names actually sent. filename=presence. Parsers decide “file or field” by whetherfilenameis present.form.append("photo", blob)without a third argument sendsfilename="blob"; appending a string sends no filename at all, and the server sees a text field.- The closing
--boundary--. A body that ends without it was truncated. The echo server flags this explicitly. --trace-asciishows curl’s view of the exchange, headers first and then body bytes, with non-printable bytes as dots — enough to see every boundary line without flooding the terminal with binary.
Reading the same request in DevTools
A useful habit when a browser upload fails: right-click the request, “Copy as cURL”, point the copied command at the echo server, and add --trace-ascii -. You now have the exact headers the browser sent, reproducible in a terminal, and you can change one thing at a time until it works.
Configuration gotchas
Error: Multipart: Boundary not found (busboy/multer) or Missing boundary in multipart/form-data. The request’s Content-Type has no boundary parameter. Remove the manually set header so fetch, XMLHttpRequest or curl can set it.
MulterError: Unexpected field. A file part arrived under a name the route does not declare. Compare the part names printed by the echo server with the upload.single() or upload.fields() configuration.
Error: Unexpected end of form. The body ended before the closing delimiter — truncated by a proxy size limit, a timeout, or a client that closed the connection. Compare Content-Length with bytes received.
Files arrive as filename="blob". The client appended a Blob without a filename. Pass a third argument to FormData.append, or wrap the blob in a File with a name.
Debugging on the server side of the wire
Sometimes the client is right and the server is not, and the fastest way to prove it is to look at what the server process actually received — which is not always what the client sent. Anything between the two can change the body: a load balancer that buffers and re-chunks it, an API gateway that base64-encodes binary bodies before passing them to a function, a WAF that strips parts it considers suspicious, a body-parser middleware that consumed the stream before your multipart handler ran.
Three checks isolate the layer. First, log content-type and content-length at the very start of the request handler, before any middleware, and compare with what the client sent; a changed boundary or length means an intermediary rewrote the request. Second, count bytes as the multipart parser sees them — busboy emits data events per part, so a running total per part tells you whether the file part arrived whole. Third, check for middleware order: a JSON or URL-encoded body parser registered globally in Express will not parse multipart, but some configurations read the raw stream to check it, leaving an empty stream for multer. The symptom is a request with a correct content-length whose file field is empty.
Serverless platforms deserve a special mention. AWS API Gateway with a Lambda proxy integration delivers multipart bodies base64-encoded, with isBase64Encoded: true, and only if the binary media types include multipart/form-data; otherwise it mangles the binary as UTF-8 text and every image arrives corrupted by a few percent of its bytes. Decode the body before handing it to a multipart parser, or — better for large files — do not send files through the gateway at all and use a presigned URL, as in presigned URL vs server proxy trade-offs.
A decision tree for multipart failures
Verification
Once the fix is in, confirm the body is well formed and the server sees what it should:
# Well-formed: boundary present, every part named, closing delimiter found.
curl -sS http://localhost:8787/upload -F 'title=Holiday' -F 'photo=@IMG_1.jpg;type=image/jpeg'
# echo server prints:
# content-type: multipart/form-data; boundary=------------------------4f1c...
# part 0: Content-Disposition: form-data; name="title" -> 7 bytes
# part 1: Content-Disposition: form-data; name="photo"; filename="IMG_1.jpg"
# Content-Type: image/jpeg -> 2318402 bytes
# [end delimiter found after part 2]
# Against the real endpoint: the file field is populated and sized correctly.
curl -sS https://api.example.com/upload -F 'photo=@IMG_1.jpg' -w '\nHTTP %{http_code}\n'
Frequently Asked Questions
Why does curl use a different boundary each time?
Boundaries are random so they are unlikely to appear inside file content. Any value works as long as the header and the body agree; never hard-code one and never compare bodies across requests by boundary.
Should the file part’s Content-Type be trusted?
No. It is whatever the client chose — curl guesses from the extension, browsers from the OS. Validate the bytes on the server, as in validating file signatures with libmagic in Node.js.
How do I see what a mobile app sends?
Route the device through a debugging proxy (mitmproxy, Charles) or point the app at the echo server on your LAN. The echo output is the same regardless of client, which makes comparing the app with curl straightforward.