formidable
formidable parses an incoming Node.js request that carries form data, mostly file uploads. You hand it a raw http.IncomingMessage, it streams the multipart body, writes each file to disk as it arrives, and gives you back two objects: fields for the text inputs and files for the uploads. Because it streams, a 2 GB upload never sits in memory. It is not middleware and knows nothing about Express, Koa, or routing; you call form.parse(req) yourself from inside whatever handler you already have. Four parsers ship as plugins (multipart, urlencoded, JSON, octet-stream) and you can switch them off individually. The escape hatch that matters is options.fileWriteStreamHandler, which lets you return your own Writable so bytes go straight to S3 or Azure instead of the local filesystem.
Still the reference streaming multipart parser for Node, and the fileWriteStreamHandler hook makes direct-to-S3 uploads genuinely easy. Choose it when you want control over the stream, and choose multer or @fastify/multipart when you would rather have your framework do the wiring.
Use it if
- You are handling large file uploads and cannot buffer them: formidable streams each part to a Writable as it arrives, so peak memory stays flat regardless of file size
- You want to stream uploads directly to object storage: fileWriteStreamHandler receives the file object and returns your own Writable, so nothing ever touches local disk
- You are on plain node:http, or a framework where you already have the raw request stream, and do not want to adopt framework-specific upload middleware
- You need per-request limits enforced during parsing rather than after: maxFileSize, maxTotalFileSize, maxFiles, maxFields, and maxFieldsSize all abort the stream mid-upload
- You are on Express and want the ordinary thing: multer is middleware, gives you req.files, and handles the wiring formidable makes you write by hand every route
- You are on Next.js App Router, Hono, or any Web Fetch runtime: formidable needs a Node request stream, and a standard Request object with a body stream will not work without an adapter
- You are upgrading from v1 or v2: in v3 every field and every file is an array, so fields.title becomes fields.title[0] and files.avatar becomes files.avatar[0] at every single call site, and the firstValues helper only papers over the fields half
- You want TypeScript types in the box: the package ships no .d.ts, so you depend on the community-maintained @types/formidable, which lags the runtime package
- You care about release cadence: the last stable publish was 3.5.4 in April 2025, v4 has sat at 4.0.0-rc.6 since May 2025, and the README itself steers new projects toward the separate formidable-mini package
- You forget to clean up: files land in os.tmpdir() and formidable never deletes them, so a busy endpoint slowly fills the disk unless you unlink after handling
Setup reality
npm install formidable and there is no build step, no peer dependency, and no native code; it pulls in three small packages (once, dezalgo, @paralleldrive/cuid2). It is a dual ESM/CommonJS package with proper exports maps, so both import and require work. The version floor is confusing: package.json declares engines node >=14.0.0 while the README says the project requires Node.js >= 20, and only the README reflects what is actually tested. The real setup pain is behavioural. Uploads go to os.tmpdir() unless you set uploadDir, and if that directory does not exist your files are silently discarded with no error at all, so create it at boot or set createDirsFromUploads. Filenames are replaced with a 25-character cuid2 and the extension is dropped unless you pass keepExtensions or your own filename function. Nothing cleans up temp files for you. And if any earlier middleware already consumed the request body, express.json() and body-parser included, formidable receives an empty stream and just hangs or returns nothing.
Patterns
Parse an upload and await the resultparse-with-promise
import formidable from "formidable";
const form = formidable({ uploadDir: "./uploads", keepExtensions: true });
const [fields, files] = await form.parse(req);
console.log(fields.title); // ['My photo'] <- array
console.log(files.avatar[0].filepath);Omitting the callback returns a promise resolving to a two-element array. In v3 every field and file is an array even when the form sent one value, which is the single biggest source of bugs when migrating from v2.
Handle an upload route in Expressparse-in-express
app.post("/api/upload", (req, res, next) => {
const form = formidable({ maxFileSize: 10 * 1024 * 1024 });
form.parse(req, (err, fields, files) => {
if (err) return next(err);
res.json({ fields, files });
});
});Do not mount express.json() or any body parser in front of this route. Those middlewares consume the request stream, and formidable then sees nothing and never calls back.
Get plain values instead of one-element arraysflatten-single-values
import { firstValues } from "formidable/src/helpers/firstValues.js";
import { readBooleans } from "formidable/src/helpers/readBooleans.js";
const [multi, files] = await form.parse(req);
const fields = firstValues(form, multi, ["tags"]); // keep tags as array
const body = readBooleans(fields, ["newsletter", "terms"]);
// body.newsletter === true when the checkbox was tickedfirstValues only unwraps fields, never files, so files.avatar[0] stays an array index. readBooleans exists because an unchecked HTML checkbox sends nothing at all, so a missing key has to become false rather than undefined.
Cap size and count before the disk fillsenforce-upload-limits
const form = formidable({
maxFiles: 5,
maxFileSize: 5 * 1024 * 1024, // per file
maxTotalFileSize: 20 * 1024 * 1024, // whole request
maxFields: 50,
maxFieldsSize: 1 * 1024 * 1024,
});Defaults are permissive: maxFiles is Infinity and maxFileSize is 200 MB. maxTotalFileSize defaults to maxFileSize, so with several files a request can legitimately be much larger than you expected unless you set it explicitly.
Tell a size rejection apart from a broken requesthandle-error-codes
import formidable, { errors as formidableErrors } from "formidable";
try {
const [fields, files] = await form.parse(req);
} catch (err) {
if (err.code === formidableErrors.biggerThanMaxFileSize) {
return res.status(413).json({ error: "File too large" });
}
res.writeHead(err.httpCode || 400);
res.end(String(err));
}The exported error names are plain integers (biggerThanMaxFileSize is 1016), not strings, and every error carries both .code and .httpCode. Partial files already written to uploadDir are not removed when parsing aborts.
Reject non-images while they uploadfilter-by-mimetype
let cancelled = false;
const form = formidable({
filter({ name, originalFilename, mimetype }) {
const ok = Boolean(mimetype && mimetype.startsWith("image/"));
if (!ok) cancelled = true;
return ok && !cancelled;
},
});Returning false skips the part silently; form.parse still resolves successfully. Set an outer flag as shown, or call form.emit('error', ...) inside the filter, if a bad file should fail the whole request. The mimetype comes from the client and is trivially spoofed, so sniff the bytes before trusting it.
Choose where files land and what they are calledcontrol-filename-and-dir
import { mkdirSync } from "node:fs";
import { randomUUID } from "node:crypto";
const uploadDir = new URL("../uploads/", import.meta.url).pathname;
mkdirSync(uploadDir, { recursive: true });
const form = formidable({
uploadDir,
keepExtensions: true,
filename: (name, ext) => `${randomUUID()}${ext}`,
});If uploadDir does not exist the uploads are discarded with no error, so create it at startup or pass createDirsFromUploads. Never build the filename from originalFilename without sanitising: a name containing ../ is a path traversal straight out of your upload directory.
Send bytes to object storage instead of diskstream-to-s3
import { PassThrough } from "node:stream";
import { Upload } from "@aws-sdk/lib-storage";
const uploads = [];
const form = formidable({
fileWriteStreamHandler: (file) => {
const pass = new PassThrough();
uploads.push(new Upload({
client: s3,
params: { Bucket: "my-bucket", Key: file.newFilename, Body: pass },
}).done());
return pass;
},
});
await form.parse(req);
await Promise.all(uploads);With this option set, formidable creates a VolatileFile and writes nothing locally, so file.filepath points at a path that does not exist. You own back-pressure and error handling on the stream you return, and you must await your own upload promises after parse resolves.
Get a hash of each file for freechecksum-uploads
const form = formidable({ hashAlgorithm: "sha256" });
const [, files] = await form.parse(req);
const file = files.document[0];
console.log(file.hash); // hex digest stringThe digest is computed from the stream while it is being written, so it costs one pass rather than re-reading the file. Any algorithm crypto.createHash accepts works; the default is false, meaning no hashing.
Report server-side progresstrack-upload-progress
const form = formidable({});
form.on("progress", (bytesReceived, bytesExpected) => {
const pct = bytesExpected ? (bytesReceived / bytesExpected) * 100 : 0;
console.log(`${pct.toFixed(1)}%`);
});
form.on("fileBegin", (name, file) => {
console.log("receiving", file.originalFilename);
});bytesExpected comes from the Content-Length header and is null when the client uses chunked transfer encoding, so guard the division. For a progress bar in the browser, use the XHR upload progress event instead; this fires on the server.
Delete the temp files you were left withcleanup-temp-files
import { unlink } from "node:fs/promises";
const [fields, files] = await form.parse(req);
try {
await persist(files.avatar[0]);
} finally {
await Promise.all(
Object.values(files).flat().map((f) => unlink(f.filepath).catch(() => {})),
);
}formidable never removes what it wrote. Without a finally block, every rejected or failed request leaves a file behind in os.tmpdir(), which is how upload endpoints quietly run a server out of disk.
Accept multipart onlydisable-unused-parsers
import formidable, { multipart } from "formidable";
const form = formidable({ enabledPlugins: [multipart] });
// json, querystring and octetstream parsing are now offThe default enabledPlugins list is [octetstream, querystring, multipart, json]. Narrowing it means a request with an unexpected content type fails with the noParser error instead of being parsed by a path you never intended to expose.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| multer | npm | You are on Express and want upload middleware that populates req.file and req.files with a one-line route decorator |
| busboy | npm | You want the lowest-level streaming multipart parser and intend to write all the file handling yourself |
| @fastify/multipart | npm | You are on Fastify and want uploads integrated with its request lifecycle, schemas, and limits |