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+fetchwhen 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
FormDatayou 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
- An evergreen browser, or Node 20.11+, where
FormData,File,Blob,crypto.getRandomValuesandfetchare all globals. - TypeScript configured with
"lib": ["DOM", "DOM.Iterable", "ES2022"]if you are copying the typed snippets; strip the annotations for plain ESM. - An endpoint running a real multipart parser —
express.json()ignores this content type and leavesreq.bodyempty. Set one up with parsing multipart/form-data in a Node server. curl7.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 typedBlobgives the part a realContent-Type: application/jsonheader instead oftext/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 toJSON.parsedefensively.new File([file], file.name, { type })— re-wrapping is cheap. TheFileconstructor 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, socafé.movfrom 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
headerskey. Addingheaders: { "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-wildcardAccess-Control-Allow-Origin.- The
signalis passed straight through. Compose caller cancellation with a deadline usingAbortSignal.anyas set out in aborting uploads with AbortController and timeouts.
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.
The three body shapes are not interchangeable, and the differences decide which one your endpoint can actually accept.
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.