Sending Multiple Files and Fields in One Request
Append each file under the same key (form.append("files", file, file.name) in a loop), put per-file metadata in a JSON field sent before the files and keyed by a client-generated ID that you also encode in each file part’s name or filename, and on the server stream the parts in order, enforcing a maximum file count, a per-file size and a total size before any bytes are buffered.
A gallery upload with captions, a support ticket with three attachments and a description, a property listing with photos in a chosen order: all of these want many files plus structured data in one submission. Multipart can carry them, but the format has no arrays, no nesting and no types — only a flat sequence of named parts. The conventions you choose for names and ordering decide whether the server can reliably tell which caption belongs to which photo. This page belongs to multipart form data explained in upload fundamentals and browser APIs. For many large files, one request per file is often better; aggregating progress across multiple files covers the UI for that.
When to use this approach
- A handful of small-to-medium files (up to tens of megabytes in total) must be submitted atomically with a form — all or nothing.
- The server needs metadata per file (caption, order, rotation, alt text) alongside the bytes.
- You control both client and server, so you can agree on part names and ordering.
Prerequisites
- Browser
FormDataandfetch(every current browser). - A streaming multipart parser on the server —
busboy1.x in Node, or your framework’s equivalent — rather than one that buffers everything first. - Agreed limits: maximum files, maximum bytes per file and in total, allowed types.
- Familiarity with how a multipart body is laid out — see debugging multipart bodies with curl and DevTools if you want to inspect one.
Flat parts, structured meaning
Multipart gives you an ordered list of parts, each with a name, optionally a filename and a content type. Everything else — which caption goes with which file, what order photos appear in — is convention. The convention that holds up is: metadata first, as one JSON part; then files, each tagged with an ID that the metadata refers to.
Implementation
Client: build the body in the order the server expects.
interface Item { id: string; file: File; caption?: string; alt?: string }
export async function submitGallery(endpoint: string, title: string, items: Item[]): Promise<Response> {
const form = new FormData();
form.append("title", title);
// 1. Manifest first — small, and it lets the server validate before reading files.
const manifest = {
items: items.map((it, order) => ({
id: it.id, order, caption: it.caption ?? "", alt: it.alt ?? "", size: it.file.size, type: it.file.type,
})),
};
form.append("manifest", new Blob([JSON.stringify(manifest)], { type: "application/json" }), "manifest.json");
// 2. Files, each filename prefixed with its id so the server can match it.
for (const it of items) {
const safeName = it.file.name.replace(/[^\w.\- ]+/g, "_").slice(0, 120);
form.append("files", it.file, `${it.id}__${safeName}`);
}
// Let fetch set the multipart Content-Type (with boundary).
return fetch(endpoint, { method: "POST", body: form });
}
// Usage
const items: Item[] = Array.from(document.querySelector<HTMLInputElement>("#photos")!.files ?? [])
.map((file) => ({ id: crypto.randomUUID().slice(0, 8), file }));
items[0].caption = "Kitchen";
const res = await submitGallery("/api/listings/42/photos", "Spring listing", items);
console.log(res.status);
Server: stream the parts, enforce limits as they arrive, match files to the manifest.
import { createServer, type IncomingMessage } from "node:http";
import busboy from "busboy";
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
const LIMITS = { files: 20, fileSize: 25 * 1024 * 1024, total: 150 * 1024 * 1024, fields: 10, fieldSize: 64 * 1024 };
interface Manifest { items: { id: string; order: number; caption: string; alt: string; size: number }[] }
async function handle(req: IncomingMessage, dir: string): Promise<object> {
const declared = Number(req.headers["content-length"] ?? 0);
if (declared > LIMITS.total) throw Object.assign(new Error("request too large"), { status: 413 });
const bb = busboy({ headers: req.headers, limits: { files: LIMITS.files, fileSize: LIMITS.fileSize,
fields: LIMITS.fields, fieldSize: LIMITS.fieldSize } });
let manifest: Manifest | null = null;
const fields: Record<string, string> = {};
const saved: { id: string; path: string; bytes: number; truncated: boolean }[] = [];
const writes: Promise<void>[] = [];
bb.on("field", (name, value) => { fields[name] = value; });
bb.on("file", (name, stream, info) => {
if (name === "manifest") {
let json = "";
stream.setEncoding("utf8").on("data", (d: string) => { json += d; }).on("end", () => {
manifest = JSON.parse(json) as Manifest;
});
return;
}
if (name !== "files" || !manifest) { stream.resume(); return; } // drain unexpected parts
const id = info.filename.split("__")[0];
if (!manifest.items.some((i) => i.id === id)) { stream.resume(); return; }
const path = join(dir, `${id}.bin`);
let bytes = 0;
stream.on("data", (c: Buffer) => { bytes += c.length; });
writes.push(pipeline(stream, createWriteStream(path)).then(() => {
saved.push({ id, path, bytes, truncated: (stream as unknown as { truncated?: boolean }).truncated === true });
}));
});
await new Promise<void>((resolve, reject) => {
bb.on("close", resolve).on("error", reject);
bb.on("filesLimit", () => reject(Object.assign(new Error("too many files"), { status: 413 })));
req.pipe(bb);
});
await Promise.all(writes);
if (!manifest) throw Object.assign(new Error("manifest missing or not first"), { status: 400 });
const truncated = saved.filter((s) => s.truncated);
if (truncated.length) throw Object.assign(new Error(`file(s) over ${LIMITS.fileSize} bytes`), { status: 413 });
const m = manifest as Manifest;
const missing = m.items.filter((i) => !saved.some((s) => s.id === i.id)).map((i) => i.id);
if (missing.length) throw Object.assign(new Error(`missing files: ${missing.join(",")}`), { status: 400 });
return { title: fields.title, photos: m.items.sort((a, b) => a.order - b.order)
.map((i) => ({ ...i, bytes: saved.find((s) => s.id === i.id)!.bytes })) };
}
createServer(async (req, res) => {
const dir = join("/tmp/uploads", crypto.randomUUID());
await mkdir(dir, { recursive: true });
try {
const out = await handle(req, dir);
res.writeHead(201, { "Content-Type": "application/json" }).end(JSON.stringify(out));
} catch (e) {
const status = (e as { status?: number }).status ?? 500;
res.writeHead(status, { "Content-Type": "application/json", Connection: "close" })
.end(JSON.stringify({ error: (e as Error).message }));
}
}).listen(8080);
Line-by-line on the decisions that matter
- Repeating the key
files. Multipart has no arrays; appending the same name several times is how HTML forms withmultiplesend files, and every parser collects repeated names. Avoidfiles[]andfiles[0]unless your framework expects them — they are conventions, not part of the format. - Manifest as a file part with
application/json. Sending it as aBlobrather than a string field avoids field-size limits and makes its type explicit. Sending it first means the server can reject a bad submission (too many items, disallowed types) before reading a single file byte. - Matching by an ID prefix in the filename. Part order is preserved by the browser and by busboy, so matching by position would work — until a client retries, reorders, or omits a file. An explicit ID survives all of those.
- Draining unexpected parts with
stream.resume(). Busboy will not move to the next part until the current stream is consumed. Ignoring a stream without resuming it stalls the whole request. limitspassed to busboy. Limits enforced during parsing stop reading at the limit; limits checked after buffering have already paid for the memory. Thetruncatedflag tells you a file hitfileSize.Connection: closeon errors. When rejecting mid-body, closing the connection stops the client sending the rest of a large body into a request you have already refused.
Configuration gotchas
Files arrive before the manifest. A client built the form in a different order (appending files as they are selected, the manifest at submit). The server above rejects that with a 400; either enforce order on the client or buffer file parts to disk until the manifest arrives and match afterwards.
Error: Unexpected end of form on large submissions. The total body exceeded a proxy or gateway limit and was cut off. Check client_max_body_size in nginx and gateway limits — raising nginx and Cloudflare upload size limits lists them.
Non-ASCII filenames arrive garbled. Browsers send UTF-8 in filename=, but some older parsers decode as Latin-1. Busboy 1.x handles UTF-8 with defParamCharset: "utf8"; never use the client filename as a storage path anyway — generate your own.
One slow file blocks the rest. A single request uploads its parts sequentially, so a 20 MB video delays three thumbnails behind it. That is inherent to one-request multipart; for mixed sizes, use one request per file.
One request or many?
Making per-file uploads atomic
When you switch to one request per file, the all-or-nothing guarantee of a single request disappears — three of five photos may upload and the user closes the tab. Restore it with a two-phase pattern. First, each file is uploaded independently (to your server or straight to storage with a presigned URL) into a staging area, tagged with the client-generated ID and a draft submission ID. Second, the client sends a small commit request containing the manifest: the draft ID, the ordered list of item IDs and their metadata. The server checks that every referenced file exists in staging with the expected size, then moves them into place and creates the records in one transaction.
Staged files that never get committed are garbage by definition. A lifecycle rule on the staging prefix deletes them after a day, so abandoned drafts cost nothing to clean up — the approach in setting up S3 lifecycle rules for temporary uploads. The commit request is tiny and idempotent: retrying it after a lost response either finds the submission already committed or commits it now.
Verification
# Three files with a manifest, in the right order: 201 with photos sorted by order.
curl -sS http://localhost:8080/api/listings/42/photos \
-F 'title=Spring listing' \
-F 'manifest=@manifest.json;type=application/json' \
-F 'files=@kitchen.jpg;filename=a1__kitchen.jpg' \
-F 'files=@hall.jpg;filename=b2__hall.jpg' \
-F 'files=@garden.jpg;filename=c3__garden.jpg' -w '\nHTTP %{http_code}\n'
# Twenty-one files: rejected at the limit with 413, without reading the rest.
args=(); for i in $(seq 1 21); do args+=(-F "files=@tiny.jpg;filename=x$i__tiny.jpg"); done
curl -sS http://localhost:8080/api/listings/42/photos -F 'manifest=@manifest.json' "${args[@]}" -o /dev/null -w '%{http_code}\n'
Frequently Asked Questions
Can I nest JSON by using field names like items[0][caption]?
Some frameworks (PHP, Rails, qs-style parsers) expand bracketed names into nested structures. It works within those ecosystems but is not part of multipart, and every server library interprets it slightly differently. A single JSON manifest part is portable and explicit.
Does the browser preserve the order I append parts?
Yes. FormData serialises entries in insertion order, and the multipart body carries them in that order. Proxies do not reorder parts. It is still better not to rely on order for matching — use IDs.
How do I show progress for a multi-file multipart request?
XMLHttpRequest.upload.onprogress reports bytes of the whole body, which you can map onto files using their sizes and the manifest order. fetch has no upload progress; see fetch upload progress vs XMLHttpRequest.