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
# 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.
# TypeScript declarations are separate
npm install --save-dev @types/form-data
Output: DefinitelyTyped declarations. Always install for TS projects.
# 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). The3.xline 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
| Package | Purpose |
|---|---|
@types/form-data | TS declarations (still useful even though built-in types exist). |
combined-stream | Used internally by form-data to concatenate streams. |
asynckit | Async control flow library used internally. |
mime-types | Used internally to guess content-type from filename. |
formidable | The receiving end — parse multipart bodies on the server. |
busboy | Lower-level multipart parser, used by Fastify/Express plugins. |
multer | Express middleware for multipart uploads. |
Alternatives
| Library | Trade-off |
|---|---|
FormData (built-in, Node 18+) | Web-standard. Zero-dep. Doesn't stream — Blob is in-memory. Pick for small uploads + simplicity. |
undici's FormData | Built into Node 18+. Same as the built-in. |
got form helpers | got.post(url, { form: {...} }) — auto-builds form-data. Pick when using got. |
manual multipart/form-data construction | Roll your own if you have unusual requirements. Avoid. |
stream-multipart / busboy-formdata | Specialised, niche. |
Common gotchas
form-data'sFormDatais NOT the web standard. Same name; different class.form-data's exposesgetHeaders(),getBoundary(),pipe()— the standard doesn't. Don't mix the two in TypeScript.- 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. appendis non-async. Even when appending a stream, it doesn't drain — the form serializes on.pipe()or.getBuffer(). Don'tawaitappend.form.getLengthSync()only works on simple inputs. Streams and unknown-length inputs throw. Useform.getLength(cb)(async) or send chunked.- The bundled
combined-streamhas a maxBuffer behaviour — for very large files, ensure the consumer reads as fast as the producer writes. Backpressure isn't automatic. - Browser
FormData≠form-datain TS unification. Universal modules need careful typing —globalThis.FormDataandimport FormData from "form-data"are different types.
Real-world recipes
Basic multipart POST with fetch
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:
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
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)
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:
{ "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
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)
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)
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:
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:
form.pipe(req); // streams; no memory blow-up
vs
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:
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).
// 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 size | Recommended |
|---|---|
| <100 KB | Either; getBuffer simpler |
| 100 KB - 10 MB | Either; favour pipe |
| >10 MB | pipe 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:
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-streamdependency bumped — internal change; no API impact.- TS types improved.
- Drop-in upgrade. Almost no breakages reported.
npm install form-data@^4
Output:
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:
// 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.
- 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). - 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. - 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). - Streaming uploads + auth. Authenticate before opening the stream — don't let attackers consume bandwidth on rejected uploads.
combined-stream(transitive) had no known issues; keepform-dataat^4.0.1for latest fixes.
Testing & CI integration
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:
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
| Tool | Integration |
|---|---|
axios | Accepts a form-data instance as data; use form.getHeaders() |
node-fetch (v2/v3) | Accepts form-data as body; pass headers |
got | got.post(url, { body: form, headers: form.getHeaders() }) |
request (deprecated) | Native support (legacy) |
superagent | .attach(), .field() — wraps form-data internally |
multer | Server-side: parses multipart bodies in Express |
formidable | Server-side: parses multipart bodies, framework-agnostic |
@fastify/multipart | Fastify 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 forreq.on("error", ...).- Server returns 400/415 — missing or wrong
Content-Typeheader. Always passform.getHeaders(). PayloadTooLargeErroron the server — server's body-size limit. Increase, or chunk the upload.- Upload hangs forever — server expects
Content-Length; you sent chunked. Useform.getLength(cb)and set the header. - TS: "Type 'FormData' is not assignable" — built-in
FormDataglobal is colliding with importedFormData. Rename the import or use platformFormData. - File arrives at server as 0 bytes —
fs.createReadStreamended before pipe (rare). Ensure you create the stream just-in-time. getHeaders()is undefined — using webFormData(notform-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-datais Node-only. - You're on Workers / Edge / Deno / Bun edge runtime. Use platform
FormData.form-datamay not work in those runtimes. - You're uploading a single JSON object. Don't use multipart — use
application/jsonbody, much simpler. - You're uploading from CLI scripts.
curl --formand tooling likehttpieis often more convenient than a Node script.
See also
- JavaScript: fetch — passing form bodies via fetch
- Concept: HTTP — multipart/form-data RFC and structure
- JavaScript: node runtime — streams and pipe semantics