Implementing multipart/form-data in Vanilla JavaScript

Pass a FormData object to fetch and never set Content-Type yourself — that is the whole implementation for 95% of uploads. The remaining 5%, where you need a boundary you chose, a Content-Length you can compute before the first byte leaves, or a body that streams instead of buffering, needs a serialiser you write yourself. This page gives you both, complete.

The wire format those bytes conform to is described in multipart form data explained; what follows is the client code that produces them, and the four error strings you will hit while getting there.

When to use this approach

  • Use FormData + fetch when you are posting to an endpoint you control, from a browser, and the body is files plus a few fields. Zero dependencies, no framing bugs, and the browser streams each file from disk without copying it into your heap.
  • Write your own serialiser when the boundary must be deterministic (golden-file tests, request signing), when you need the exact byte length before you send, or when you are emitting the body from a runtime whose FormData you do not trust.
  • Reach for a library only when you want resumability. Restarting a 4 GB upload from byte zero is a protocol problem, not a serialisation problem — that is what resumable upload state machines are for.

Prerequisites

  1. An evergreen browser, or Node 20.11+, where FormData, File, Blob, crypto.getRandomValues and fetch are all globals.
  2. TypeScript configured with "lib": ["DOM", "DOM.Iterable", "ES2022"] if you are copying the typed snippets; strip the annotations for plain ESM.
  3. An endpoint running a real multipart parser — express.json() ignores this content type and leaves req.body empty. Set one up with parsing multipart/form-data in a Node server.
  4. curl 7.75+ for the verification step.

Implementation

The builder below is the version worth shipping: it normalises filenames, repairs the empty Blob.type that trips server allowlists, and keeps metadata as one JSON part rather than a dozen scalar fields.

export interface UploadPayload {
  files: File[];
  metadata: Record<string, unknown>;
}

export interface UploadResult {
  ok: boolean;
  status: number;
  stored: string[];
}

/** Build the entry list. Nothing is serialised until this is handed to fetch. */
export function buildForm({ files, metadata }: UploadPayload): FormData {
  const form = new FormData();

  // set() replaces any earlier entry with this name; append() would duplicate it.
  form.set(
    "metadata",
    new Blob([JSON.stringify(metadata)], { type: "application/json" }),
    "metadata.json",
  );

  for (const file of files) {
    // An empty file.type serialises as application/octet-stream and fails
    // allowlists, so fall back to the extension the OS gave us.
    const type = file.type || guessType(file.name);
    const normalised = type === file.type ? file : new File([file], file.name, { type });
    // Third argument sets filename= on the part; NFC keeps macOS names comparable.
    form.append("files", normalised, file.name.normalize("NFC"));
  }

  return form;
}

function guessType(name: string): string {
  const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
  const table: Record<string, string> = {
    webm: "video/webm", mp4: "video/mp4", mov: "video/quicktime",
    png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", wav: "audio/wav",
  };
  return table[ext] ?? "application/octet-stream";
}

export async function postMultipart(
  url: string,
  payload: UploadPayload,
  signal?: AbortSignal,
): Promise<UploadResult> {
  const response = await fetch(url, {
    method: "POST",
    body: buildForm(payload), // no headers object at all — see below
    credentials: "same-origin",
    signal,
  });

  if (response.status === 413) {
    throw new Error("413 — body exceeded the proxy limit; send fewer files per request");
  }
  if (!response.ok) {
    throw new Error(`upload failed: HTTP ${response.status} ${response.statusText}`);
  }

  const body = (await response.json()) as { stored: string[] };
  return { ok: true, status: response.status, stored: body.stored };
}

Line-by-line on the parameters that matter

  • form.set("metadata", blob, "metadata.json") — wrapping JSON in a typed Blob gives the part a real Content-Type: application/json header instead of text/plain. Multer and busboy will then hand it to you as a file part with a parseable type rather than a string field you have to JSON.parse defensively.
  • new File([file], file.name, { type }) — re-wrapping is cheap. The File constructor takes a reference to the same backing bytes; it does not copy the 300 MB video into the heap.
  • file.name.normalize("NFC") — macOS emits NFD, so café.mov from a Mac and the same name typed on Linux produce different byte sequences and different object keys. Normalise once, at the boundary of your system.
  • No headers key. Adding headers: { "Content-Type": "multipart/form-data" } is the single most common failure in this whole topic, and the diagram below shows why.
  • credentials: "same-origin" is the default and is stated for the reader’s benefit; switch to "include" only for cross-origin cookie auth, which forces a preflight and a non-wildcard Access-Control-Allow-Origin.
  • The signal is passed straight through. Compose caller cancellation with a deadline using AbortSignal.any as set out in aborting uploads with AbortController and timeouts.
Which layer chooses the boundary string Three stages: your code appends entries with no Content-Type header, the browser serialiser invents the boundary and writes the header, and the parser reads it back. Setting the header yourself skips the middle stage and produces a 400. The boundary is chosen in stage 2, not stage 1 Your code append(name, file, filename) no headers object boundary: unknown Browser serialiser picks ----WebKitFormBoundary plus 16 random characters boundary: chosen here Parser reads boundary= from the request header boundary: consumed Write the header yourself and stage 2 never runs: Content-Type: multipart/form-data (no boundary=) 400 Error: Multipart: Boundary not found fetch offers no supported way to supply your own boundary alongside a FormData body
The header and the body are generated together — supplying half of the pair is what breaks the request.

Writing the serialiser yourself

FormData deliberately hides the boundary, and there is no API to override it. When you need control, stop using it and emit RFC 7578 bytes directly. The encoder below is under fifty lines and produces a body byte-identical in structure to Chromium’s, with a boundary you supply.

const CRLF = "\r\n";
const enc = new TextEncoder();

export interface Part {
  name: string;
  value: string | Blob;
  filename?: string;
  contentType?: string;
}

export function randomBoundary(): string {
  const bytes = crypto.getRandomValues(new Uint8Array(12));
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
  return `----vanillaBoundary${hex}`; // 31 characters, well inside the 70-char limit
}

/** WHATWG escaping: only these three characters are escaped, and only as percent codes. */
function escapeParam(value: string): string {
  return value.replace(/"/g, "%22").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
}

export function partHeader(part: Part, boundary: string): Uint8Array {
  const lines = [`--${boundary}`];
  let disposition = `Content-Disposition: form-data; name="${escapeParam(part.name)}"`;

  if (part.value instanceof Blob) {
    const fallback = part.value instanceof File ? part.value.name : "blob";
    disposition += `; filename="${escapeParam(part.filename ?? fallback)}"`;
  }
  lines.push(disposition);

  if (part.value instanceof Blob) {
    const type = part.contentType ?? part.value.type;
    lines.push(`Content-Type: ${type || "application/octet-stream"}`);
  }

  lines.push("", ""); // blank line terminating the part headers
  return enc.encode(lines.join(CRLF));
}

export function encodeMultipart(parts: Part[], boundary = randomBoundary()) {
  const chunks: BlobPart[] = [];
  for (const part of parts) {
    chunks.push(partHeader(part, boundary));
    chunks.push(part.value instanceof Blob ? part.value : enc.encode(part.value));
    chunks.push(enc.encode(CRLF));
  }
  chunks.push(enc.encode(`--${boundary}--${CRLF}`));

  const contentType = `multipart/form-data; boundary=${boundary}`;
  const body = new Blob(chunks, { type: contentType });
  return { body, boundary, contentType, contentLength: body.size };
}

Three details carry the correctness. Every line terminator is CRLF, both bytes — a lone \n is why hand-rolled bodies fail with Error: Unexpected end of form. Escaping follows WHATWG rather than RFC 5987, so a UTF-8 filename goes out as raw bytes inside the quoted parameter and only ", CR and LF are percent-escaped. And a Blob built from other blobs holds references: new Blob([videoFile, header]) costs a few hundred bytes of heap, not the size of the video, which is the same laziness described in slicing large files with Blob.slice.

Sending it is the mirror image of the FormData path — here you must set the header, because nothing else knows your boundary:

export async function postEncoded(url: string, parts: Part[]): Promise<Response> {
  const { body, contentType, contentLength } = encodeMultipart(parts);
  console.info(`sending ${contentLength} bytes`);

  return fetch(url, {
    method: "POST",
    body,                                  // Blob.type would supply this anyway
    headers: { "Content-Type": contentType },
  });
}

Computing Content-Length before you send

Because every part’s size is known, the total is arithmetic, not measurement. That matters when you want to reject an oversize request in the client before spending forty seconds discovering the proxy’s limit, or when you are signing the request from a non-browser runtime that requires an explicit length.

export function multipartLength(parts: Part[], boundary: string): number {
  let total = 0;
  for (const part of parts) {
    total += partHeader(part, boundary).byteLength;
    total += part.value instanceof Blob
      ? part.value.size
      : enc.encode(part.value).byteLength;
    total += 2; // the CRLF that terminates this part's content
  }
  return total + boundary.length + 6; // "--" + boundary + "--" + CRLF
}

For one 48 MB clip.webm and a 96-byte JSON manifest at a 31-character boundary, that returns 50 332 108 — the manifest part costs 145 bytes of framing, the file part 162, and the closing delimiter 37. Compare it against the limit you actually configured in raising Nginx and Cloudflare upload size limits and fail fast on the client.

Streaming the body instead of buffering it

A Blob body is already lazy in the browser, but in a Worker or a Node service that assembled parts in memory it is not. Yielding the body as a ReadableStream keeps peak heap at one slice regardless of file size, and lets you count bytes as they leave.

async function* multipartChunks(
  parts: Part[],
  boundary: string,
  sliceBytes: number,
): AsyncGenerator<Uint8Array> {
  for (const part of parts) {
    yield partHeader(part, boundary);
    if (part.value instanceof Blob) {
      for (let offset = 0; offset < part.value.size; offset += sliceBytes) {
        const end = Math.min(offset + sliceBytes, part.value.size);
        yield new Uint8Array(await part.value.slice(offset, end).arrayBuffer());
      }
    } else {
      yield enc.encode(part.value);
    }
    yield enc.encode(CRLF);
  }
  yield enc.encode(`--${boundary}--${CRLF}`);
}

export function multipartStream(
  parts: Part[],
  boundary: string,
  sliceBytes = 1024 * 1024,
  onBytes: (sent: number) => void = () => {},
): ReadableStream<Uint8Array> {
  const iterator = multipartChunks(parts, boundary, sliceBytes);
  let sent = 0;
  return new ReadableStream<Uint8Array>({
    async pull(controller) {
      const { value, done } = await iterator.next();
      if (done) {
        controller.close();
        return;
      }
      sent += value.byteLength;
      onBytes(sent);
      controller.enqueue(value);
    },
    async cancel() {
      await iterator.return(undefined);
    },
  });
}

export function postStreamed(url: string, parts: Part[], boundary: string): Promise<Response> {
  return fetch(url, {
    method: "POST",
    body: multipartStream(parts, boundary, 1024 * 1024, (sent) => console.debug(sent)),
    headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
    duplex: "half",
  } as RequestInit & { duplex: "half" });
}

The pull callback is only invoked when the network has drained the previous chunk, so backpressure is handled for you and the generator never runs ahead of the socket. That onBytes counter is the only genuine upload-progress signal fetch offers; the wider picture, including the TransformStream variant, is in tracking upload progress with a TransformStream.

States of the streaming multipart encoder The generator emits part headers, then loops emitting one-mebibyte slices of the file, then a CRLF terminator, then either returns to the header state for the next entry or emits the closing delimiter and closes the stream. One pass over the entry list, one slice in memory 1 · part headers ~150 ASCII bytes 2 · pump slices 1 MiB per pull() 3 · CRLF terminator 2 bytes, then decide until offset === blob.size more entries in the list list done 4 · closing delimiter then controller.close() Peak heap: one slice flat for a 4 GB file
Backpressure comes free: the generator advances only when pull() is called, so the socket sets the pace.

The three body shapes are not interchangeable, and the differences decide which one your endpoint can actually accept.

Capability matrix for the three ways to build the body A five-row comparison of FormData, a hand-built Blob and a ReadableStream across boundary control, known length, buffering, plain fetch support and upload progress. Same bytes, three different contracts FormData Hand-built Blob ReadableStream Boundary you choose no yes yes Length known before send browser only yes, exact no, chunked Never buffers the file yes browser only yes Plain fetch, HTTP/1.1 yes yes HTTP/2 + duplex Byte-level send progress XHR only XHR only yes
Pick the leftmost column that satisfies your constraints — each step right costs you either a header, a protocol version, or a known length.

Where part ordering changes behaviour

FormData is an ordered entry list and both serialisers above preserve insertion order, which usually does not matter — until it does. An S3 browser upload built on a signed policy requires every policy field to precede the file part, because the service stops reading at the file and ignores anything after it. Append the file last, always, and read presigned POST vs presigned PUT for browser uploads before you wire it up. The same rule helps your own server: a manifest part that arrives first lets a streaming parser reject the request on metadata alone, before it has written a single megabyte to disk.

Configuration reference

Key Type Default Effect
Part.filename string file.name, else "blob" Written as filename= on the part. Omit it for a raw Blob and every upload lands as a file literally named blob.
Part.contentType string blob.type, else application/octet-stream Becomes the part’s Content-Type. Servers with a media-type allowlist reject the octet-stream fallback.
randomBoundary() length number 31 chars RFC 2046 permits 1–70. Longer boundaries cost bytes per part; shorter ones weaken the collision guarantee.
sliceBytes number 1048576 Bytes read per pull(). Below 64 KiB the per-call overhead dominates; above 8 MiB you lose the memory benefit.
duplex "half" required Mandatory for a ReadableStream body. Chromium also requires HTTPS with HTTP/2 and refuses to follow redirects.
credentials string "same-origin" "include" sends cookies cross-origin and forces Access-Control-Allow-Credentials: true plus an explicit origin.
keepalive boolean false Cannot be combined with a stream body, and caps the whole request at 64 KiB — useless for uploads.
Blob.type on the body string "" If set, fetch uses it as Content-Type, so encodeMultipart works even if you forget the headers object.

Configuration gotchas

Error: Multipart: Boundary not found — busboy’s message, surfacing as a 400 or 415. You set Content-Type: multipart/form-data by hand alongside a FormData body, so the request declared the media type with no boundary parameter and the parser had nothing to scan for. Delete the header entirely. The same message appears when an interceptor adds a default Content-Type to every outgoing request; check for a global config before you blame the upload code.

TypeError: Failed to execute 'fetch' on 'Window': The duplex member must be specified for a request with a streaming body — Chromium’s wording; undici says RequestInit: duplex option is required when sending a body. Add duplex: "half". If the request then fails with net::ERR_H2_OR_QUIC_REQUIRED, the origin negotiated HTTP/1.1 and Chromium will not stream a request body over it — fall back to the Blob encoder, or read uploading with ReadableStream request bodies for the full support matrix.

Refused to set unsafe header "Content-Length" — logged by Chromium and silently ignored by Firefox. Content-Length is a forbidden header name; the browser always computes it. multipartLength() is for client-side capacity checks and for non-browser runtimes, never for the wire.

A part arrives as filename="blob" with type application/octet-stream — no error, just a rejected or misnamed object. You appended a Blob (from a canvas export, a fetch response, or a slice) without the third argument. Give every binary part an explicit filename and a real media type, and remember that the type you send is a claim, not a fact: why browser MIME types are unreliable explains why the server must sniff regardless.

Verification

The strongest check on a hand-written encoder is to parse it back with the platform’s own parser. Response.formData() runs the same multipart implementation the browser uses for incoming responses, so a clean round trip proves your framing, not just your intent. Run this in a browser console or with node --input-type=module on Node 20.11+.

import { strict as assert } from "node:assert";
import { encodeMultipart, multipartLength, randomBoundary } from "./multipart.js";

const boundary = randomBoundary();
const clip = new File([new Uint8Array(3_500_000)], "café.webm", { type: "video/webm" });
const { body, contentType, contentLength } = encodeMultipart(
  [
    { name: "metadata", value: JSON.stringify({ albumId: "alb_2291" }) },
    { name: "files", value: clip },
  ],
  boundary,
);

assert.equal(contentLength, multipartLength(
  [
    { name: "metadata", value: JSON.stringify({ albumId: "alb_2291" }) },
    { name: "files", value: clip },
  ],
  boundary,
));

const parsed = await new Response(body, { headers: { "Content-Type": contentType } }).formData();
const returned = parsed.get("files") as File;

assert.equal(parsed.get("metadata"), '{"albumId":"alb_2291"}');
assert.equal(returned.name, "café.webm");        // proves the UTF-8 filename survived
assert.equal(returned.type, "video/webm");
assert.equal(returned.size, 3_500_000);          // proves no CRLF leaked into the content
console.log(`round trip ok — ${contentLength} bytes, boundary ${boundary}`);

A size that is two bytes too large is the classic symptom of counting the trailing CRLF as content. Once the round trip passes, confirm the server agrees, and compare the byte count it reports against your computed length:

curl -sS -D - -o /dev/null \
  -F 'metadata={"albumId":"alb_2291"};type=application/json' \
  -F 'files=@clip.webm;type=video/webm' \
  http://localhost:3000/api/uploads
# HTTP/1.1 201 Created
# content-type: application/json

Add --trace-ascii /dev/stdout to that command to print the exact bytes curl put on the wire, delimiters included, which is the fastest way to diff your encoder against a known-good sender.

Frequently Asked Questions

Can I reuse one boundary for every request?

You can, and you should not. Nothing in the format escapes a boundary that appears inside a part’s content, so a fixed string that happens to occur in an uploaded file truncates the body and hands the parser garbage. Generate 96 bits from crypto.getRandomValues per request; the only fair exception is a test fixture where you compare against a golden file.

How do I add a per-file checksum without reading the file twice?

Hash while you slice. The streaming encoder already walks the blob in 1 MiB pieces, so feed each slice into an incremental digest and append the result as a trailing text part, or compute it in advance following computing file checksums in the browser with Web Crypto. Web Crypto’s subtle.digest is one-shot only, so incremental hashing needs a small library or a Worker.

Should I Base64 the file into a JSON field instead?

Only if an intermediary forces text. Base64 inflates the payload by 33% plus line breaks, and it forces the whole file through your heap on both ends because there is no streaming decoder in the JSON path. The measured comparison lives in Base64 vs binary encoding.

Which parts of this work in Node without a DOM?

All of it from Node 18 onward, and comfortably from 20.11: FormData, Blob, File, fetch, ReadableStream and crypto.getRandomValues are globals with no import. The one difference is the generated boundary — undici emits ----formdata-undici- plus digits — which is exactly why a snapshot test should pin the boundary rather than assert on the browser’s.

Where should retries live, in the encoder or the caller?

The caller. An encoder that retries internally cannot know whether the body was partially consumed, and a ReadableStream body is single-use — a retried fetch on the same stream throws TypeError: Request body object should not be disturbed or locked. Rebuild the parts array per attempt and schedule delays with the jitter policy in implementing exponential backoff for failed chunks.