mrkeyoor.com_
Thu 06 Aug 07:40 UTC
npmUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5The v2 to v3 jump changed every field and file into an array, which touches every call site, and v3 also moved from a constructor to a factory call. Within v3 the surface has been steady since 2022, but v4 is a separate rewrite that has been in release-candidate state since May 2025.
Docs4/5The README is long and genuinely useful: every option documented with its default, worked examples for http, Express and Koa, the full event list, the File shape, and the error codes. What is missing is a v2-to-v3 migration section, and the stated Node requirement contradicts package.json.
Maintenance3/5The repo was pushed August 2026 with 27 open issues (45 counting PRs), so it is not abandoned, but the last stable release was April 2025 and 4.0.0-rc.6 has not moved since May 2025. The README's own note says a few maintainers are trying to deal with it and asks for help.
Ecosystem4/5Around 24.6M downloads a week and it sits under koa-better-body and other upload layers. The plugin API is real but almost nobody writes plugins, and the biggest ecosystem problem is that deprecated v1 and v2 are still widely installed, which the README flags as a security concern.

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
Skip it if

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 ticked

firstValues 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 string

The 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 off

The 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

PackageRegistryPick it when
multernpmYou are on Express and want upload middleware that populates req.file and req.files with a one-line route decorator
busboynpmYou want the lowest-level streaming multipart parser and intend to write all the file handling yourself
@fastify/multipartnpmYou are on Fastify and want uploads integrated with its request lifecycle, schemas, and limits