cheat sheet

form-data

Package-level reference for form-data on npm — file uploads, streaming, headers, and the modern FormData web standard alternative.

form-data

What it is

form-data is the Node.js multipart/form-data body builder — what you reach for when you need to POST a file upload (or mixed fields + file) from a server-side context.

It pre-dates the web FormData standard (now built into Node 18+ via undici) and remains very widely used because it's a readable stream — you can pipe straight to an HTTP request without buffering the file in memory. axios, node-fetch, got, and many SDKs accept a FormData instance from this package as the body.

The web-standard FormData (now in Node) is a different class with similar but not identical semantics. Both work; form-data is more stream-friendly for large uploads, the standard FormData is zero-dep.

Install

bash
# npm / pnpm / yarn / bun
npm install form-data
pnpm add form-data
yarn add form-data
bun add form-data

Output: runtime dep. ~10 KB unpacked.

bash
# TypeScript declarations are separate
npm install --save-dev @types/form-data

Output: DefinitelyTyped declarations. Always install for TS projects.

bash
# Modern alternative — built into Node 18+ (no install needed)
const fd = new FormData();
fd.append("file", new Blob([buf]), "upload.bin");

Output: the platform FormData lives on globalThis since Node 18. Use it when you don't need streaming.

Versioning & Node support

  • Current major line is 4.x (stable for years; minor security/perf fixes). The 3.x line is feature-equivalent and still maintained for Node ≥10.
  • Pure JS; runs on Node ≥14 (4.x) or Node ≥10 (3.x), Bun, Deno.
  • CJS-first; works in ESM via interop (import FormData from "form-data").
  • Always a runtime dependency — your code creates the multipart body at request time.
  • Strict semver — majors rare; tied to Node baseline bumps.

Package metadata

  • Maintainer: Alex Indigo (@alexindigo) + form-data org
  • Project home: github.com/form-data/form-data
  • Docs: github.com/form-data/form-data#readme
  • npm: npmjs.com/package/form-data
  • License: MIT
  • First released: 2012
  • Downloads: ~50 million per week

Peer dependencies & extras

PackagePurpose
@types/form-dataTS declarations (still useful even though built-in types exist).
combined-streamUsed internally by form-data to concatenate streams.
asynckitAsync control flow library used internally.
mime-typesUsed internally to guess content-type from filename.
formidableThe receiving end — parse multipart bodies on the server.
busboyLower-level multipart parser, used by Fastify/Express plugins.
multerExpress middleware for multipart uploads.

Alternatives

LibraryTrade-off
FormData (built-in, Node 18+)Web-standard. Zero-dep. Doesn't stream — Blob is in-memory. Pick for small uploads + simplicity.
undici's FormDataBuilt into Node 18+. Same as the built-in.
got form helpersgot.post(url, { form: {...} }) — auto-builds form-data. Pick when using got.
manual multipart/form-data constructionRoll your own if you have unusual requirements. Avoid.
stream-multipart / busboy-formdataSpecialised, niche.

Common gotchas

  1. form-data's FormData is NOT the web standard. Same name; different class. form-data's exposes getHeaders(), getBoundary(), pipe() — the standard doesn't. Don't mix the two in TypeScript.
  2. You must include Content-Type: multipart/form-data; boundary=... in the request — form.getHeaders() returns the right value. Forgetting the boundary is the #1 reason uploads fail.
  3. append is non-async. Even when appending a stream, it doesn't drain — the form serializes on .pipe() or .getBuffer(). Don't await append.
  4. form.getLengthSync() only works on simple inputs. Streams and unknown-length inputs throw. Use form.getLength(cb) (async) or send chunked.
  5. The bundled combined-stream has a maxBuffer behaviour — for very large files, ensure the consumer reads as fast as the producer writes. Backpressure isn't automatic.
  6. Browser FormDataform-data in TS unification. Universal modules need careful typing — globalThis.FormData and import FormData from "form-data" are different types.

Real-world recipes

Basic multipart POST with fetch

typescript
import FormData from "form-data";
import fs from "node:fs";
import fetch from "node-fetch";

const form = new FormData();
form.append("title", "Vacation photo");
form.append("tags", "summer");
form.append("tags", "beach");
form.append("file", fs.createReadStream("./photo.jpg"));

const res = await fetch("https://api.example.com/upload", {
  method: "POST",
  body: form as any,
  headers: form.getHeaders(),
});

console.log(res.status, await res.text());

Output:

text
200 {"id":"abc123","url":"https://example.com/photos/abc123.jpg"}

form.getHeaders() returns { "content-type": "multipart/form-data; boundary=..." }. Pass it directly to the request — without this, the server can't parse the body.

File upload with custom filename + content-type

typescript
import FormData from "form-data";
import fs from "node:fs";

const form = new FormData();
form.append("file", fs.createReadStream("./photo.jpg"), {
  filename: "vacation-2026.jpg",
  contentType: "image/jpeg",
  knownLength: fs.statSync("./photo.jpg").size,
});

Output: the third argument lets you override the filename (useful when uploading a file with a temporary name) and content-type (auto-detected from extension, but explicit is safer for unknown types).

knownLength lets form-data set Content-Length correctly — needed for some endpoints that reject chunked transfers.

Nested fields (bracket notation)

typescript
const form = new FormData();
form.append("user[name]", "Alice");
form.append("user[email]", "alice@example.com");
form.append("tags[]", "admin");
form.append("tags[]", "user");

Output: the parser on the receiving end (e.g. Rails, Express + multer) reconstructs:

json
{ "user": { "name": "Alice", "email": "alice@example.com" }, "tags": ["admin", "user"] }

This is the multipart equivalent of bracket-notation in query strings. Most multipart parsers support it; check your server.

Multipart with axios

typescript
import axios from "axios";
import FormData from "form-data";
import fs from "node:fs";

const form = new FormData();
form.append("file", fs.createReadStream("./photo.jpg"));
form.append("description", "A photo");

const res = await axios.post("https://api.example.com/upload", form, {
  headers: form.getHeaders(),
  maxBodyLength: Infinity,    // axios default cap is 10MB — raise for large files
});

console.log(res.data);

Output: maxBodyLength: Infinity removes axios' default 10MB body limit. The headers must include the boundary; axios picks form-data's headers when you pass them.

Pipe to a raw HTTP request (no fetch)

typescript
import FormData from "form-data";
import fs from "node:fs";
import https from "node:https";

const form = new FormData();
form.append("file", fs.createReadStream("./big-video.mp4"));

const req = https.request({
  method: "POST",
  hostname: "api.example.com",
  path: "/upload",
  headers: form.getHeaders(),
});

form.pipe(req);
req.on("response", (res) => {
  console.log("status:", res.statusCode);
  res.on("data", (chunk) => process.stdout.write(chunk));
});

Output: streaming pipe — no in-memory buffering. Critical for multi-gigabyte uploads.

Get the body as a buffer (small uploads only)

typescript
import FormData from "form-data";

const form = new FormData();
form.append("name", "Alice");
form.append("avatar", Buffer.from("..."), { filename: "a.png", contentType: "image/png" });

const buf: Buffer = await new Promise((resolve, reject) => {
  form.getBuffer((err, buf) => err ? reject(err) : resolve(buf));
});

console.log(buf.length, form.getHeaders());

Output:

text
1432  { 'content-type': 'multipart/form-data; boundary=--------------------------XXXXX' }

Use getBuffer only for small forms — buffers the entire body in memory. For files, pipe or use fetch directly.

Production deployment

Streaming, not buffering

For any file upload >1MB, pipe rather than buffer:

typescript
form.pipe(req);   // streams; no memory blow-up

vs

typescript
const buf = await getBuffer(form);   // entire file in memory

The streaming path is why form-data exists; the platform FormData doesn't stream as well.

Set Content-Length when possible

Some endpoints (S3, GCS) require Content-Length and reject chunked transfers. form-data's getLength is async because it needs to inspect each part:

typescript
form.getLength((err, length) => {
  const headers = { ...form.getHeaders(), "content-length": String(length) };
  // ... make the request
});

For streams with unknown length (fs.createReadStream on a file you've stat'd), pass knownLength when appending.

Edge runtime compatibility

form-data is Node-only — uses streams. On Cloudflare Workers, Vercel Edge, Deno, Bun, use the platform FormData instead (works there but doesn't stream).

typescript
// On Workers/Edge — use the platform FormData
const fd = new FormData();
fd.append("file", new Blob([buf]), "upload.bin");
const res = await fetch(url, { method: "POST", body: fd });

The platform FormData doesn't need getHeaders() — fetch fills in the boundary automatically.

Performance tuning

Stream vs buffer trade-off

Input sizeRecommended
<100 KBEither; getBuffer simpler
100 KB - 10 MBEither; favour pipe
>10 MBpipe mandatory — avoid memory pressure

Avoid double-conversion

Don't JSON.stringify an object then append as a field if the server expects bracket-notation. Use form.append("user[name]", value) directly — saves serialize/deserialize.

Reuse the FormData instance

A FormData instance is single-use (one pipe, one consume). For repeat uploads, build a new instance — but factor field-construction into helpers.

Chunked transfer when stream length is unknown

If you can't know the stream length (e.g. piping from another HTTP response), don't try to compute Content-Length. Send chunked:

typescript
const req = https.request({
  // ...
  headers: form.getHeaders(),   // omit content-length
});
form.pipe(req);

Most servers accept chunked multipart fine. S3 and some legacy APIs don't.

Version migration guide

form-data has had two long-lived majors:

v3 → v4

  • Node ≥14 required (3.x ran on Node ≥10).
  • combined-stream dependency bumped — internal change; no API impact.
  • TS types improved.
  • Drop-in upgrade. Almost no breakages reported.
bash
npm install form-data@^4

Output:

text
added 1 package in 1s

Common migration breaks

None of note. The API has been stable since 2017.

v4 → "v5"

Not yet announced. The package is in maintenance mode — major work would be needed only if Node's built-in FormData displaces it entirely.

Migrate to platform FormData?

Many codebases are dropping form-data in favour of Node 18+'s built-in:

typescript
// form-data way
import FormData from "form-data";
import fs from "node:fs";
const form = new FormData();
form.append("file", fs.createReadStream("./f.bin"));
const res = await fetch(url, { method: "POST", body: form as any, headers: form.getHeaders() });

// platform way (Node 18+)
import { openAsBlob } from "node:fs";
const fd = new FormData();
fd.append("file", await openAsBlob("./f.bin"), "f.bin");
const res = await fetch(url, { method: "POST", body: fd });

openAsBlob (Node 19+) returns a Blob backed by a file handle — not loaded into memory. Combined with the platform FormData + fetch, you can drop form-data entirely.

Security considerations

form-data has had no critical CVEs. The risk surface is what you upload, not the library.

  1. Don't trust filenames from upstream. If you accept a multipart upload and re-forward it, sanitise the filename — attackers may inject path traversal (../../../etc/passwd).
  2. Content-type from filename can be spoofed. Don't trust form-data's auto-detected MIME for security decisions — re-detect from the file contents server-side.
  3. Multipart bombs. A pathological multipart body can have millions of tiny parts. The receiving server should enforce a part-count limit (Multer's files: 5).
  4. Streaming uploads + auth. Authenticate before opening the stream — don't let attackers consume bandwidth on rejected uploads.
  5. combined-stream (transitive) had no known issues; keep form-data at ^4.0.1 for latest fixes.

Testing & CI integration

typescript
import { describe, it, expect } from "vitest";
import FormData from "form-data";

describe("form-data", () => {
  it("builds a multipart body", async () => {
    const form = new FormData();
    form.append("name", "Alice");
    form.append("file", Buffer.from("hello"), { filename: "h.txt" });

    const buf: Buffer = await new Promise((res, rej) =>
      form.getBuffer((e, b) => e ? rej(e) : res(b))
    );

    const body = buf.toString("utf8");
    expect(body).toMatch(/name="name"\r\n\r\nAlice/);
    expect(body).toMatch(/filename="h.txt"/);
    expect(body).toMatch(/hello/);
  });
});

Output:

text
 PASS  form-data > builds a multipart body

For integration tests, post against httpbin.org/post or a local test server — verifies the boundary, headers, and parts round-trip.

Ecosystem integrations

ToolIntegration
axiosAccepts a form-data instance as data; use form.getHeaders()
node-fetch (v2/v3)Accepts form-data as body; pass headers
gotgot.post(url, { body: form, headers: form.getHeaders() })
request (deprecated)Native support (legacy)
superagent.attach(), .field() — wraps form-data internally
multerServer-side: parses multipart bodies in Express
formidableServer-side: parses multipart bodies, framework-agnostic
@fastify/multipartFastify multipart plugin (built on busboy)

Troubleshooting common errors

  • Error: write after end — piped to a closed request; usually the request errored out before pipe started. Listen for req.on("error", ...).
  • Server returns 400/415 — missing or wrong Content-Type header. Always pass form.getHeaders().
  • PayloadTooLargeError on the server — server's body-size limit. Increase, or chunk the upload.
  • Upload hangs forever — server expects Content-Length; you sent chunked. Use form.getLength(cb) and set the header.
  • TS: "Type 'FormData' is not assignable" — built-in FormData global is colliding with imported FormData. Rename the import or use platform FormData.
  • File arrives at server as 0 bytesfs.createReadStream ended before pipe (rare). Ensure you create the stream just-in-time.
  • getHeaders() is undefined — using web FormData (not form-data). They're different classes.

When NOT to use this

  • You're on Node 18+ and don't need streaming. Use the built-in FormData + fetch — zero dep, simpler.
  • You're in the browser. Use the built-in FormData. form-data is Node-only.
  • You're on Workers / Edge / Deno / Bun edge runtime. Use platform FormData. form-data may not work in those runtimes.
  • You're uploading a single JSON object. Don't use multipart — use application/json body, much simpler.
  • You're uploading from CLI scripts. curl --form and tooling like httpie is often more convenient than a Node script.

See also