Multipart Form Data Explained

multipart/form-data is the only request body a browser will assemble for you that carries binary files and text fields in the same round trip, and it is also the one where a single well-meaning header line turns a working upload into Error: Multipart: Boundary not found. The format has no length prefixes and no schema: it is a byte stream cut into pieces by a string that the sender invents, which is why every failure mode is a parsing failure and every fix starts with knowing exactly what went on the wire.

This topic sits inside upload fundamentals and browser APIs and covers the format itself — the grammar, the serialiser, the costs, and the places where the specification and real parsers disagree. The two articles beneath it take the two halves further: implementing multipart/form-data in vanilla JavaScript for the client code, and parsing multipart/form-data in a Node server for the receiving side.

Prerequisites

  • [ ] An evergreen browser, or Node 20.11+ where FormData, Blob, File and fetch are all globals
  • [ ] A File or Blob to send — see File API and Blob objects if you are still getting one out of an <input> or a drop event
  • [ ] An endpoint that runs a real multipart parser; express.json() and express.urlencoded() both ignore this content type entirely and leave req.body as {}
  • [ ] curl 7.75+ for the verification commands, which use --trace-ascii
  • [ ] TypeScript with "lib": ["DOM", "DOM.Iterable", "ES2022"] if you are copying the typed snippets
  • [ ] Shell access to whatever reverse proxy sits in front of the app — it has an opinion about body size and you will need to check it

How it works

multipart/form-data is defined by RFC 7578, which obsoleted RFC 2388 in 2015, and it inherits its skeleton from the generic MIME multipart rules in RFC 2046. Two documents matter more than either in practice: the WHATWG HTML standard, which specifies exactly how a browser turns a form into bytes, and the WHATWG XHR standard, which defines the FormData object. Where RFC 7578 and the WHATWG specifications disagree — and they do, notably about filenames — browsers follow WHATWG, so that is what your parser will actually receive.

The media type carries one mandatory parameter, boundary, and it is declared once, in the request’s Content-Type header. Everything below that is body.

The body is a delimited byte stream, not a structure

There is no table of contents, no part count, no per-part length field. A parser reads forwards, looking for the byte sequence CRLF -- boundary. When it finds one, the preceding bytes were a part’s content; when the delimiter is followed by another --, the body is over. Anything after that closing delimiter is the epilogue and must be discarded. Anything before the first delimiter is the preamble and is likewise ignored, which is why a stray blank line at the top of a hand-built body is tolerated by some parsers and fatal to others.

Inside a part, the shape is a miniature HTTP message: header lines, one empty line, then content that runs until the next delimiter. Every line terminates with CRLF, both bytes, no exceptions — a lone \n is the single most common defect in hand-rolled bodies and produces Error: Unexpected end of form from busboy because the parser never recognises the delimiter it is scanning for.

A two-part multipart body annotated line by line The raw bytes of a request carrying one text field and one PDF file, with callouts marking the boundary parameter, the opening delimiter, the part headers, the blank separator line, the verbatim value and the closing delimiter. One request, byte for byte Content-Type: multipart/form-data; boundary=----W3bK1tB0undary9Qz CRLF ------W3bK1tB0undary9Qz CRLF Content-Disposition: form-data; name="album" CRLF CRLF summer-2026 CRLF ------W3bK1tB0undary9Qz CRLF Content-Disposition: form-data; name="files"; filename="report.pdf" CRLF Content-Type: application/pdf CRLF CRLF %PDF-1.7 … 25 165 003 raw bytes … %%EOF CRLF ------W3bK1tB0undary9Qz-- CRLF declared once, in the header two dashes open a part part headers, then a blank line value copied verbatim filename rides in the header, not the body binary, unencoded, 1:1 trailing -- ends the body CRLF marks a literal carriage return plus line feed; a lone newline breaks every parser
Only three things are structural: the boundary parameter, the delimiter lines, and the blank line that ends each part's headers.

Where the boundary comes from

RFC 2046 allows a boundary of 1 to 70 characters drawn from a restricted set — letters, digits, and '()+_,-./:=? plus space, which may not be the last character. Nothing in the format escapes the boundary if it appears inside a part’s content, so correctness rests entirely on the sender picking a string that does not occur in the payload. Senders solve this with entropy rather than scanning: Chromium emits ----WebKitFormBoundary followed by 16 random Base64-ish characters, Firefox emits 27 dashes followed by a long random decimal, and Node’s undici emits ----formdata-undici- plus 11 random digits. All three give you well over 60 bits of randomness, which is why the collision you fear has never actually happened to anyone.

The one collision risk that is real comes from the other direction: a sender that reuses a fixed boundary across requests, or derives it from user input. Never build a boundary out of a filename or a request ID. If you are constructing a body by hand rather than letting FormData do it, generate it from crypto.getRandomValues and treat it as opaque.

FormData is an ordered entry list, not a buffer

The single most useful thing to understand about FormData is that appending a file does not copy it. The object is specified as an ordered list of name/value entries; a file entry holds a File, which is itself a reference to bytes the browser has on disk or in a memory-mapped region. No serialisation happens at append() time. The body is produced only when the entry list is handed to fetch, XMLHttpRequest.send, or the Request constructor, at which point the browser walks the list, writes headers, and streams each file’s bytes straight from its backing store into the socket.

That has three practical consequences. Appending a 25 MB video costs you tens of bytes of heap, not 25 MB. The total Content-Length is computable up front because every entry has a known size, so the browser sends a fixed-length body rather than chunked transfer encoding. And a File whose underlying disk file is deleted or modified between append() and send() produces a read error at send time, surfacing in Chromium as a failed request with net::ERR_UPLOAD_FILE_CHANGED.

From entry list to socket at send time A FormData entry list holding a string, a Blob and a 25 MB File reference feeds a serialiser that runs only when the body is sent, which emits headers and streams file bytes to the network. FormData holds references; bytes move at send time Entry list in memory order name kind size 1 albumId string 11 B 2 manifest Blob 84 B 3 files File 25 MB heap held: ~95 bytes entry 3 is a disk handle Serialiser picks a boundary walks entries in order writes part headers reads the file lazily Content-Length 26 214 559 On the wire POST /uploads Content-Type: multipart/ form-data; boundary=… 3 parts, one body no base64, no copy fixed length, not chunked Nothing is encoded until you pass the list to fetch, XHR or new Request(). Mutating the entry list after the request starts has no effect on that request.
The serialiser is the only place bytes are produced, which is why the boundary cannot be known before you send.

What the framing costs

The framing is plain ASCII and it is not free. With a 37-character boundary, one file part costs about 134 bytes: 41 for the delimiter line, 63 for a typical Content-Disposition line with a short filename, 26 for Content-Type, 2 for the blank line, and 2 for the CRLF that terminates the content. Add roughly 41 bytes for the closing delimiter and 60 for the request’s own Content-Type header.

At normal file sizes this is noise — 134 bytes on a 2 MB photo is 0.007%. It stops being noise the moment you use multipart as a container for many small items, which people do when batching thumbnails, tiles, telemetry blobs or per-chunk uploads. Split one mebibyte into ten thousand parts and the framing outweighs the payload.

Wire size for one mebibyte of payload as part count rises Horizontal bars showing total request body size for 1 MiB of payload split into 1, 100, 1000, 5000 and 10000 parts, growing from 1.05 MB to 2.39 MB as per-part framing accumulates. Framing overhead for 1 MiB of payload 1 part 1.05 MB (+0.02%) 100 parts 1.06 MB (+1.3%) 1 000 parts 1.18 MB (+12.8%) 5 000 parts 1.72 MB (+63.9%) 10 000 parts 2.39 MB (+127.8%) Assumes 134 bytes of delimiter and part headers per part at a 37-character boundary
Multipart is a file container, not a record format — past a few hundred parts the delimiters become the payload.

Two other properties follow from the same design. Nothing is Base64-encoded, so binary crosses the wire at 1:1 and you avoid the 33% inflation described in Base64 vs binary encoding; RFC 7578 §4.7 explicitly deprecates Content-Transfer-Encoding and browsers have never emitted it. And because the parser must scan for a delimiter it cannot know in advance where a part ends, so no intermediary can skip over a file without reading it — which is exactly why a proxy in front of your app buffers or streams the whole body before your handler is invoked.

Step-by-step implementation

1. Collect files and metadata into one entry list

Build the entry list explicitly rather than harvesting a <form> element, so you control the field names your parser will see and can reject oversized items before a socket is opened.

export interface UploadDraft {
  albumId: string;
  caption: string;
  files: readonly File[];
}

/** Refuse anything a single part should not carry; chunk it instead. */
const MAX_PART_BYTES = 25 * 1024 * 1024;

export function buildUploadForm(draft: UploadDraft): FormData {
  const form = new FormData();

  // Scalar values are coerced to USVStrings. Numbers and booleans arrive at the
  // server as "42" and "true" with no type information, so stringify on purpose.
  form.append('albumId', draft.albumId);
  form.append('caption', draft.caption);
  form.append('clientSentAt', new Date().toISOString());

  // A Blob appended without the third argument serialises as filename="blob".
  // Naming it makes the part self-describing in access logs and parser errors.
  const manifest = JSON.stringify({
    count: draft.files.length,
    totalBytes: draft.files.reduce((sum, file) => sum + file.size, 0),
  });
  form.append(
    'manifest',
    new Blob([manifest], { type: 'application/json' }),
    'manifest.json',
  );

  for (const file of draft.files) {
    if (file.size > MAX_PART_BYTES) {
      throw new RangeError(
        `${file.name} is ${file.size} B; the per-part ceiling is ${MAX_PART_BYTES} B`,
      );
    }
    // Repeating a name is legal and order-preserving: the server receives
    // files[0], files[1] … in exactly this sequence.
    form.append('files', file, file.name);
  }

  return form;
}

Reading the list back is useful in tests: [...form.keys()] yields ['albumId', 'caption', 'clientSentAt', 'manifest', 'files', 'files'] for a two-file draft, duplicates included, which is the order the serialiser will use.

2. Hand the entry list to fetch

The rule with the highest ratio of damage to typing effort: do not set Content-Type. The Request constructor derives it from the body, boundary and all. An explicit header replaces the derived one, the boundary parameter disappears, and the server reports a malformed body even though your payload is perfect.

export class UploadHttpError extends Error {
  readonly status: number;

  constructor(status: number, statusText: string, detail: string) {
    super(`HTTP ${status} ${statusText}: ${detail.slice(0, 200)}`);
    this.name = 'UploadHttpError';
    this.status = status;
  }
}

export interface UploadResult {
  id: string;
  bytesStored: number;
}

export async function submitUploadForm(
  endpoint: string,
  form: FormData,
  signal: AbortSignal,
): Promise<UploadResult> {
  const response = await fetch(endpoint, {
    method: 'POST',
    body: form,
    signal,
    credentials: 'include',
    // No Content-Type here on purpose. Adding it would overwrite
    // "multipart/form-data; boundary=----WebKitFormBoundary…" with a
    // boundary-less string and the parser would reject the body.
    headers: { 'X-Idempotency-Key': crypto.randomUUID() },
  });

  if (!response.ok) {
    const detail = await response.text();
    throw new UploadHttpError(response.status, response.statusText, detail);
  }

  return (await response.json()) as UploadResult;
}

Two headers are worth knowing about. crypto.randomUUID() requires a secure context, so this fails on a plain-HTTP staging host; the idempotency key itself is what makes a retried multipart POST safe, and the mechanics are covered in retrying fetch uploads with idempotency keys. And because that custom header is not on the CORS safelist, it is what triggers a preflight — not the body.

3. Report progress with XMLHttpRequest

fetch still has no upload-progress event in any shipping browser. If you need a byte counter for a multipart POST, XMLHttpRequest is the working answer, and its upload object emits progress against the serialised body — framing bytes included, so event.total is slightly larger than the sum of your file sizes.

export interface ProgressTick {
  fraction: number;
  loaded: number;
  total: number;
}

export function uploadWithProgress(
  endpoint: string,
  form: FormData,
  onProgress: (tick: ProgressTick) => void,
): Promise<{ status: number; body: string }> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', endpoint, true);
    xhr.responseType = 'text';
    xhr.withCredentials = true;
    xhr.timeout = 120_000;

    xhr.upload.addEventListener('progress', (event: ProgressEvent) => {
      if (!event.lengthComputable) return;
      onProgress({
        fraction: event.loaded / event.total,
        loaded: event.loaded,
        total: event.total,
      });
    });

    xhr.addEventListener('load', () => {
      resolve({ status: xhr.status, body: xhr.responseText });
    });
    xhr.addEventListener('error', () => {
      reject(new Error('Network error before any response line arrived'));
    });
    xhr.addEventListener('timeout', () => {
      reject(new Error('Upload exceeded the 120000 ms XHR timeout'));
    });
    xhr.addEventListener('abort', () => {
      reject(new DOMException('Upload aborted by the caller', 'AbortError'));
    });

    // Again: no setRequestHeader('Content-Type'). XHR derives it from the
    // FormData exactly as fetch does, and overriding it has the same effect.
    xhr.send(form);
  });
}

Expect the last progress event to fire well before the response arrives — the browser reports bytes handed to the kernel, not bytes acknowledged by the origin, so a 100%-then-wait pause of several seconds is normal on a large body. Smoothing that into an honest UI is the subject of real-time upload progress events.

4. Classify the failure before you retry

Multipart failures divide cleanly into three buckets, and retrying the wrong one is how you turn a 413 into a thousand 413s.

export type Disposition = 'retry' | 'shrink' | 'fail';

export function classifyUploadFailure(error: unknown): Disposition {
  // AbortController fired, or the user navigated away. Never retry.
  if (error instanceof DOMException && error.name === 'AbortError') return 'fail';

  if (error instanceof UploadHttpError) {
    if (error.status === 413) return 'shrink'; // body too large for a hop
    if (error.status === 415) return 'fail';   // endpoint does not accept multipart
    if (error.status === 400) return 'fail';   // malformed framing; retrying repeats it
    if (error.status === 429) return 'retry';
    if (error.status >= 500) return 'retry';
    return 'fail';
  }

  // fetch reports every transport failure as an opaque TypeError.
  if (error instanceof TypeError) return 'retry';

  return 'fail';
}

A shrink verdict means re-plan, not re-send: drop to fewer files per request, or move to chunked transfer as described in handling 413 and 507 errors during uploads. For the retry verdict, use the delay schedule from implementing exponential backoff for failed chunks rather than a fixed sleep — a synchronised retry from every client is how a recovering origin gets knocked down again.

Configuration reference

Every knob below changes what appears on the wire or what the far end accepts. Defaults are the ones you get if you write nothing.

Key Type Default Effect
FormData.append(name, blob, filename) string omitted Sets filename= on the part. Omit it for a Blob and you get filename="blob"; omit it for a File and the browser uses file.name.
FormData.set(name, value) method Replaces every existing entry with that name, keeping the first one’s position. append never replaces.
Blob.type string "" Becomes the part’s Content-Type. An empty type is serialised as application/octet-stream, which trips allowlists that expect a real media type.
fetch headers['Content-Type'] string derived Setting it discards the generated boundary. There is no supported way to supply your own.
fetch credentials string same-origin include is required for cookie auth cross-origin, and forces Access-Control-Allow-Credentials: true on the response.
XMLHttpRequest.timeout number (ms) 0 0 means no timeout at all; a stalled upload can hold a connection until the OS gives up.
busboy limits.fileSize number Infinity Truncates the part stream at the limit and sets file.truncated; it does not throw.
busboy limits.files number Infinity Emits a filesLimit event and skips further file parts silently.
busboy limits.parts number Infinity Total of fields plus files; the cheapest defence against a part-count flood.
busboy defParamCharset string 'latin1' Charset used to decode filename. Leave it and every non-ASCII filename arrives as mojibake.
multer limits.fieldNameSize number 100 Field names longer than this raise MulterError: Field name too long.
Nginx client_max_body_size size 1m Exceeding it returns 413 Request Entity Too Large before your app is reached.
Nginx client_body_buffer_size size 8k or 16k Bodies above this spill to a temp file on disk, adding latency and I/O.
Access-Control-Max-Age seconds 5 in Chromium Preflight cache lifetime; Chromium caps it at 7200 and Safari at 600.

Anything on the proxy row is worth checking first when an upload works locally and fails in production — the walkthrough is in raising Nginx and Cloudflare upload size limits.

Edge cases and gotchas

Non-ASCII filenames and the latin1 default

RFC 7578 §4.2 suggests RFC 5987 encoding (filename*=UTF-8''rapport-%C3%A9t%C3%A9.pdf) for names outside ASCII. Browsers do not do this. The WHATWG HTML standard tells them to write the name as raw UTF-8 bytes inside the quoted filename= parameter, escaping only three characters: " becomes %22, LF becomes %0A, and CR becomes %0D. Field names get the same treatment.

So rapport-été.pdf goes out as UTF-8 bytes in a quoted string. The damage is done at the other end: busboy 1.x decodes parameter values as latin1 unless told otherwise, so your server logs rapport-été.pdf and your object key is wrong forever. Set defParamCharset: 'utf8' when you construct the parser, and normalise with name.normalize('NFC') before storing, because macOS clients send NFD and the same visible filename will otherwise compare unequal.

Duplicate field names and part ordering

Repeating a name is not an error and is the normal way to send a list. RFC 7578 §5.3 requires that order be preserved, and every mainstream parser does — but not every parser exposes it that way. Express with multer gives you req.files as an array in order; a naive Object.fromEntries over parsed fields keeps only the last value; PHP silently discards all but the last unless the name ends in []. If order carries meaning, do not rely on the container: send an explicit index field, or put the ordering in the JSON manifest part.

Empty file inputs still produce a part

An <input type="file"> with nothing selected does not vanish from the body. The HTML standard requires the browser to emit a part with filename="", Content-Type: application/octet-stream, and zero bytes of content. Server code that treats “a file part exists” as “a file was uploaded” will happily store a 0-byte object. Check file.size > 0 and reject an empty filename explicitly; the same check catches the case where a user selects a file and then deletes it from disk before submitting.

Content-Length, 411, and why chunked bodies are rare here

Because every entry has a known length, the browser computes Content-Length and sends a fixed-length body. That matters because a fair number of upload endpoints — S3 among them — reject chunked request bodies with 411 Length Required. You only hit that path if you bypass FormData and pass a ReadableStream as the body yourself, which is covered in the Streams API for uploads. If you see a 411 from a plain FormData POST, something between you and the origin is re-framing the request.

CORS preflight comes from your headers, not your body

multipart/form-data is one of the three CORS-safelisted content types, alongside text/plain and application/x-www-form-urlencoded. A bare cross-origin multipart POST therefore needs no preflight at all. The preflight you are seeing is caused by something you added: an Authorization header, a custom X-Idempotency-Key, or credentials: 'include' combined with a non-safelisted header. That is usually the right trade, but it means the server must answer OPTIONS with Access-Control-Allow-Headers listing every custom header by name — a wildcard is ignored when credentials are involved. The full matrix lives in CORS configuration for uploads.

Part-count limits you did not know you had

Defaults bite here. PHP’s max_file_uploads is 20 and extra file parts are dropped without an error. Spring’s MultipartProperties caps a request at 10 MB. AWS API Gateway rejects any payload over 10 MB with a 413 that never reaches your Lambda, and its REST endpoints treat the body as text unless the content type is registered as binary. Set busboy’s limits.parts yourself even when you think the client is trustworthy — a body of 200 000 empty parts is a few megabytes on the wire and an unbounded allocation loop in a careless handler.

Client-side type checks are a UX feature, not a control

The Content-Type a browser writes into a part comes from the operating system’s file association, and a renamed executable will cheerfully claim image/png. Rejecting a bad extension in the browser saves a doomed 40 MB upload, which is worth doing; it proves nothing about the bytes. The reasoning is set out in why browser MIME types are unreliable, and the sniffing that actually decides is server-side file validation.

“Multipart upload” means something else in S3

Two unrelated meanings share a word. multipart/form-data is a MIME body format for one HTTP request. An S3 multipart upload is a stateful protocol — CreateMultipartUpload, N independent UploadPart requests, CompleteMultipartUpload — where each part is a plain binary PUT with no MIME framing anywhere. They do not compose: you do not send a multipart/form-data body to UploadPart. When someone says “switch to multipart”, establish which one they mean before you write anything; the size threshold argument is worked through in multipart vs single-PUT for files under 100 MB.

When multipart is the wrong body

Multipart earns its overhead when files and structured fields must arrive together, atomically, at an endpoint you control. Take away any of those three conditions and something simpler wins. A single file going to object storage should be a raw PUT with the bytes as the whole body — no framing, no parser, and the storage service can validate the length itself. Structured data with no binary should be JSON. And a browser uploading straight to a bucket usually wants a signed policy instead of your origin, which is the choice laid out in presigned POST vs presigned PUT for browser uploads — note that presigned POST is itself a multipart/form-data body, with the policy fields as parts, so the format follows you further than you might expect.

Choosing a request body format A decision tree splitting on body contents: one file alone leads to a raw PUT, files plus fields lead to multipart/form-data, and structured data alone leads to JSON. What is in the body? One file only no companion fields Files and fields one atomic POST No binary at all structured data only Raw PUT zero framing bytes multipart/form-data about 134 B per part application/json no base64 tax to pay Metadata can also travel as headers or a query string, leaving the body pure binary
The deciding question is never file size — it is whether fields and bytes must be committed in the same request.

One hybrid is worth knowing: put the file in a raw PUT and the metadata in headers or a follow-up JSON call. It costs an extra request and gives up atomicity, but it lets a storage service accept the bytes directly and keeps your origin out of the data path entirely.

Verification

Start with the bytes themselves. Node 20+ has FormData, Blob, File and Request as globals, so you can serialise a body and read it without a browser or a server:

// verify-multipart.mjs — run with: node verify-multipart.mjs
const form = new FormData();
form.append('albumId', 'summer-2026');
form.append(
  'manifest',
  new Blob([JSON.stringify({ count: 1 })], { type: 'application/json' }),
  'manifest.json',
);
form.append(
  'files',
  new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], 'rapport-été.pdf', {
    type: 'application/pdf',
  }),
);

const request = new Request('https://api.example.com/uploads', {
  method: 'POST',
  body: form,
});

console.log('content-type:', request.headers.get('content-type'));

const raw = new Uint8Array(await request.arrayBuffer());
console.log('body bytes:', raw.byteLength);
console.log(new TextDecoder().decode(raw).replaceAll('\r\n', '<CRLF>\n'));

The output pins down three things at once — that a boundary was generated, that the accented filename went out as UTF-8, and that every line really does end CRLF:

content-type: multipart/form-data; boundary=----formdata-undici-098167213504
body bytes: 493
------formdata-undici-098167213504
Content-Disposition: form-data; name="albumId"

summer-2026
------formdata-undici-098167213504
Content-Disposition: form-data; name="manifest"; filename="manifest.json"
Content-Type: application/json

{"count":1}
------formdata-undici-098167213504
Content-Disposition: form-data; name="files"; filename="rapport-été.pdf"
Content-Type: application/pdf

%PDF-
------formdata-undici-098167213504--

Against a live endpoint, curl builds an equivalent body and --trace-ascii prints it as it goes out. The ;type= suffix sets a part’s Content-Type, which curl otherwise guesses from the extension:

curl -sS -D - -o /dev/null \
  --trace-ascii multipart-trace.txt \
  -F 'albumId=summer-2026' \
  -F 'manifest=@manifest.json;type=application/json' \
  -F 'files=@report.pdf;type=application/pdf' \
  https://api.example.com/uploads

Then confirm the framing survived the trip with three greps over the trace: grep -c 'Content-Disposition' multipart-trace.txt should equal your part count, grep 'Content-Length:' multipart-trace.txt should show a number slightly above the sum of your file sizes, and grep -c $'\r' multipart-trace.txt should be non-zero. A Content-Length that matches your files exactly means the framing was stripped somewhere.

In DevTools, the Network panel’s Payload tab shows a friendly parsed table, which hides precisely the detail you are debugging. Click view source to see the raw body, and check the request headers for boundary= — if the Content-Type reads multipart/form-data with nothing after it, you set the header by hand somewhere. Finally, assert on the server: log req.headers['content-type'] at the top of the handler and compare the boundary there with the one the browser reported. A mismatch means a proxy re-encoded the body, which some WAF and body-inspection layers do.

Frequently Asked Questions

Why does my upload work in curl but fail in the browser with a 400?

Almost always because the browser request carries a hand-set Content-Type header and curl does not, so the browser’s body has no boundary parameter for the parser to scan for. Delete the header from your fetch options entirely — there is no correct value you can write there — and re-check the request in DevTools with view source to confirm boundary= reappears.

Can I send a multipart body without the browser, for example from Node or a CLI?

Yes. Node 20+ implements the same FormData and fetch as the browser through undici, so identical code works server-side, and curl -F builds a compliant body from files on disk. What you must not do is concatenate the body yourself with template strings — the CRLF handling and the filename escaping are the parts people get wrong, and a Buffer.concat over correctly-generated pieces is the only safe manual route.

Does multipart/form-data support compression of individual parts?

Not in any way a browser will do for you. RFC 7578 §4.7 deprecates Content-Transfer-Encoding, and no browser emits a per-part encoding header, so a part’s bytes are always literal. You can compress the file before appending it and name it accordingly, or rely on transport-level compression, which is pointless for already-compressed media such as JPEG, MP4 or ZIP.

How large can a single multipart request safely be?

The format imposes no limit, but the path does: the default Nginx ceiling is 1 MB, AWS API Gateway hard-caps at 10 MB, and many application frameworks default between 1 MB and 50 MB. Treat anything above about 100 MB as a design smell for a single request, because one failed byte at 95% means resending everything — that is the point where chunked or resumable transfer stops being optional.

Is it safe to trust the filename a client sends?

No. It is attacker-controlled UTF-8 that has already passed through two escaping rules, and it routinely contains path separators, null bytes, or ../ sequences. Generate your own storage key from a UUID, keep the client’s name only as a display label after stripping control characters, and never interpolate it into a filesystem path or an object key.