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

  1. Browser FormData and fetch (every current browser).
  2. A streaming multipart parser on the server — busboy 1.x in Node, or your framework’s equivalent — rather than one that buffers everything first.
  3. Agreed limits: maximum files, maximum bytes per file and in total, allowed types.
  4. 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.

Part layout for several files with metadata The body contains a manifest JSON part first, listing items with client IDs, captions and order. Three file parts follow, each named files with a filename prefixed by its client ID. The server reads the manifest, then matches each streamed file to its entry by ID. Manifest first, then files that reference it name="manifest" (application/json) {"items":[{"id":"a1","caption":"Kitchen","order":0}, {"id":"b2","order":1},{"id":"c3","order":2}]} name="files" filename="a1__kitchen.jpg" name="files" filename="b2__hall.jpg" name="files" filename="c3__garden.jpg" server 1. parse manifest (small, first) 2. stream each file part 3. match by id prefix Because the manifest arrives first, the server knows every rule before the first file byte.
Order and captions travel in one small JSON part; the files only need to say which entry they belong 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 with multiple send files, and every parser collects repeated names. Avoid files[] and files[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 a Blob rather 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.
  • limits passed to busboy. Limits enforced during parsing stop reading at the limit; limits checked after buffering have already paid for the memory. The truncated flag tells you a file hit fileSize.
  • Connection: close on 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?

Single multipart request versus one request per file A single request is atomic and simple but sequential, retries everything on failure and hits body size limits. One request per file allows parallelism, per-file retry and progress, and no total limit, but needs a separate commit step to make the set atomic. Choose by size and failure cost one multipart request atomic: all files or none one handler, one transaction sequential parts, one progress bar any failure resends everything subject to total body limits one request per file parallel, per-file progress retry only what failed direct-to-storage possible needs a final "commit" call with the manifest Rule of thumb: under ~20 MB total, one request; above it, or on mobile, one per file plus commit.
The manifest idea carries over: with one request per file, it simply moves into the commit call.

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.

Two-phase upload: stage files, then commit a manifest Files are uploaded in parallel to a staging area, each tagged with its item ID. A commit request carries the manifest; the server verifies all staged files, then publishes them and creates records in one transaction. Uncommitted staged files expire after a day. Stage in parallel, commit once PUT a1 → staging PUT b2 → staging PUT c3 → staging POST /commit manifest: a1, b2, c3 verify + publish one transaction Staged but never committed after 24 h → deleted by a lifecycle rule.
Staging gives parallelism and per-file retry; the commit restores atomicity.

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.