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

  1. curl 7.80 or newer (curl --version), which every current OS ships.
  2. Browser DevTools — Chrome, Edge or Firefox all show request payloads.
  3. Optionally, a local echo server to see what arrives. The Node one below needs nothing installed.
  4. The server’s expected field names — the most common mismatch is file versus upload versus files[].

Anatomy of the bytes on the wire

A multipart body annotated line by line The Content-Type header declares multipart form-data with a boundary. The body starts with two dashes and the boundary, then part headers for a text field, a blank line and its value. The next boundary starts a file part with a filename and content type, a blank line and the binary bytes. The body ends with the boundary followed by two dashes. What curl --trace-ascii shows, annotated Content-Type: multipart/form-data; boundary=------X9f2a --------X9f2a Content-Disposition: form-data; name="title" (blank line) Holiday --------X9f2a Content-Disposition: form-data; name="photo"; filename="IMG_1.jpg" Content-Type: image/jpeg (blank line) + 2 318 402 bytes --------X9f2a-- header names the boundary "--" + boundary opens a part name = the server's field key filename marks a file part type is per part, not per request trailing "--" closes the body
Every multipart bug is visible in these lines: a missing boundary, a wrong name, or a body that never reaches its closing delimiter.

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 set Content-Type by hand — often an HTTP client wrapper with a default JSON header, or a well-meaning headers: { "Content-Type": "multipart/form-data" }.
  • content-length versus 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 name of each part. Server frameworks look fields up by name: multer.single("photo") ignores a part named file. The echo output shows the names actually sent.
  • filename= presence. Parsers decide “file or field” by whether filename is present. form.append("photo", blob) without a third argument sends filename="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-ascii shows 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

Where multipart details appear in browser DevTools In the Network panel, the Headers tab shows the request Content-Type with its boundary. The Payload tab shows parsed form fields, with file parts listed as binary. View source on the payload shows the raw body lines including boundaries. Copy as cURL exports the request for replay. Network panel → select the request Headers Content-Type with boundary=----Web… Content-Length Payload parsed fields: title: Holiday photo: (binary) view source raw lines with boundaries and part headers Copy as cURL replay in a terminal against the echo server Chrome omits file bytes from the copied cURL command; re-attach them with -F 'photo=@file'.
Headers show the boundary, Payload shows the fields, view source shows the raw body — check all three.

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

Diagnosing a failed multipart upload Check whether the Content-Type header has a boundary; if not, remove the manual header. If it does, check whether bytes received equal Content-Length; if not, look for a size limit or truncation. If they match, check part names and filenames against the server's configuration. Three questions, in order 1. boundary in header? Headers tab / echo 2. all bytes arrived? length vs received 3. names match? part name, filename yes yes no: remove the manual Content-Type header no: size limit, timeout or proxy truncation no: rename the part or the server's field list Nearly every multipart failure stops at one of these three checks.
Work left to right: a missing boundary masks everything after it, so fix that first.

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.