@fastify/multipart, busboy, formidable, and multer are Node.js libraries designed to parse multipart/form-data requests, which are standard for handling file uploads and complex form submissions. multer is the de facto standard middleware for Express applications, offering a simple API for saving files to disk or memory. busboy is a high-performance streaming parser that serves as the engine for many other libraries, providing low-level control without built-in file storage. formidable is a robust, battle-tested library often used in standalone HTTP servers or frameworks like Koa, known for its ease of use and automatic file handling. @fastify/multipart is the official plugin for the Fastify framework, optimized for high throughput and async/await patterns, leveraging busboy under the hood.
Handling file uploads and multipart forms is a common requirement in backend development, but it is also one of the most error-prone areas if not managed correctly. The four main tools in the Node.js ecosystem—busboy, multer, formidable, and @fastify/multipart—approach this problem from different angles. Some prioritize raw speed and streaming control, while others focus on developer convenience and framework integration. Let's break down how they work, where they shine, and how to implement them in real-world scenarios.
The fundamental difference between these libraries lies in how they handle incoming data. busboy is a pure streaming parser. It processes data chunk by chunk as it arrives, never loading the entire file into memory unless you explicitly tell it to. This makes it incredibly efficient for large files.
multer and formidable are higher-level wrappers. They often buffer data to disk or memory to simplify the API. multer is specifically designed to act as Express middleware, intercepting requests before they reach your route handlers. @fastify/multipart bridges the gap, using busboy internally but exposing a modern async/await API that fits the Fastify ecosystem.
busboy: The Low-Level Enginebusboy gives you direct access to the stream. You must manually handle file events and decide where to pipe the data. It does not save files to disk automatically. This is perfect if you want to upload directly to Amazon S3 or another cloud service without touching your server's disk.
import Busboy from 'busboy';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
export async function handleUpload(req, res) {
const busboy = new Busboy({ headers: req.headers });
busboy.on('file', (name, file, info) => {
const { filename, mimeType } = info;
// Pipe directly to a file or cloud storage stream
const writeStream = createWriteStream(`./uploads/${filename}`);
file.pipe(writeStream);
});
busboy.on('finish', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'upload complete' }));
});
req.pipe(busboy);
}
multer: The Express Standardmulter simplifies the process for Express apps. You configure a storage engine (disk or memory) and attach the middleware to your route. It populates req.file or req.files, making access trivial.
import express from 'express';
import multer from 'multer';
import path from 'path';
const app = express();
// Configure disk storage
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`)
});
const upload = multer({ storage });
app.post('/upload', upload.single('avatar'), (req, res) => {
// File is saved automatically, info available in req.file
res.json({
filename: req.file.filename,
path: req.file.path
});
});
formidable: The Standalone Powerhouseformidable works well with raw Node.js servers or frameworks like Koa. It uses a callback or promise-based API to parse the form and save files. It automatically handles temporary file creation.
import { IncomingForm } from 'formidable';
import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
if (req.url === '/upload' && req.method.toLowerCase() === 'post') {
const form = new IncomingForm();
// Parse the form
const [fields, files] = await form.parse(req);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
uploadedFile: files.photo[0].originalFilename,
newPath: files.photo[0].filepath
}));
}
});
server.listen(3000);
@fastify/multipart: The Modern Async ApproachFor Fastify users, this plugin provides a clean, async interface. It allows you to iterate over parts of the request or access specific files directly, leveraging Fastify's schema validation and error handling.
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
const fastify = Fastify();
await fastify.register(multipart);
fastify.post('/upload', async (request, reply) => {
const data = await request.file();
if (!data) {
reply.code(400).send({ error: 'No file uploaded' });
return;
}
// Save to disk using provided utility or pipe manually
await data.toBuffer(); // Or data.toFile('./uploads')
reply.send({
filename: data.filename,
type: data.mimetype
});
});
When dealing with large files (hundreds of MBs or GBs), memory management becomes critical.
busboy is the most memory-efficient. Since it streams data, your server's RAM usage stays flat regardless of file size. You are responsible for piping the stream to a destination that can handle the load.multer can be configured for streaming, but its default diskStorage writes to the filesystem efficiently. However, if you use memoryStorage, you risk crashing your server if a user uploads a file larger than your available RAM.formidable automatically writes to temporary files on disk, preventing memory bloat. It is safe for large uploads but involves disk I/O overhead.@fastify/multipart inherits busboy's streaming efficiency. It allows you to process data chunk-by-chunk without buffering the whole file, making it ideal for high-throughput microservices.Security is paramount when accepting user files. All libraries provide hooks for validation, but the implementation differs.
multer has a fileFilter option to reject files based on MIME type or extension. It is straightforward but relies on the client-sent MIME type, which can be spoofed. You should add a second layer of validation (e.g., checking file magic numbers) after upload.const fileFilter = (req, file, cb) => {
if (file.mimetype === 'image/png') {
cb(null, true);
} else {
cb(new Error('Only PNG images are allowed'), false);
}
};
const upload = multer({ fileFilter });
busboy requires you to manually check the mimeType in the file event. Because it is low-level, you must implement your own logic to abort the stream if the file type is invalid.busboy.on('file', (name, file, info) => {
if (info.mimeType !== 'image/png') {
file.resume(); // Discard data
return;
}
// Process valid file
});
formidable allows setting maxFileSize and filter functions to validate incoming files before they are fully written.const form = new IncomingForm({
maxFileSize: 10 * 1024 * 1024, // 10MB limit
filter: ({ mimetype }) => mimetype === 'image/png'
});
@fastify/multipart integrates with Fastify's schema validation. You can define limits in the plugin options and validate MIME types using hooks or schemas before processing the file.await fastify.register(multipart, {
limits: { fileSize: 10 * 1024 * 1024 },
throwFileSizeLimit: true
});
How each library reports errors affects how you structure your try/catch blocks.
multer passes errors to the Express next(err) middleware. You need a specific error handling middleware to catch MulterError instances.busboy emits error events. If you don't attach an error listener, your application might crash on malformed requests.formidable rejects the promise or calls the error callback, making it easy to wrap in a standard try/catch block.@fastify/multipart throws standard JavaScript errors that Fastify can catch and serialize automatically according to your error handler configuration.| Feature | busboy | multer | formidable | @fastify/multipart |
|---|---|---|---|---|
| Primary Use Case | Custom streams, S3 direct upload | Express.js apps | Raw HTTP, Koa, Hono | Fastify apps |
| Learning Curve | High (Manual stream handling) | Low (Convention based) | Medium | Low (Async/Await) |
| File Storage | Manual (You pipe it) | Automatic (Disk/Memory) | Automatic (Temp Disk) | Manual or Utility helpers |
| Performance | ⭐⭐⭐⭐⭐ (Pure Stream) | ⭐⭐⭐⭐ (Efficient) | ⭐⭐⭐ (Disk I/O) | ⭐⭐⭐⭐⭐ (Stream based) |
| Framework Integration | None (Generic) | Express Only | Generic / Koa | Fastify Only |
If you are using Express, stick with multer. The ecosystem support, middleware compatibility, and simplicity make it the right tool for the job. Trying to force busboy into Express often leads to reinventing what multer already does well.
If you are using Fastify, @fastify/multipart is the only logical choice. It respects the framework's performance goals and async architecture. Using multer with Fastify is possible but discouraged as it bypasses Fastify's lifecycle optimizations.
For custom servers, Koa, or Hono, formidable offers the best balance of ease-of-use and reliability. It handles the messy details of multipart parsing so you can focus on business logic.
Finally, reach for busboy directly only if you have very specific needs—such as piping uploads directly to a cloud bucket without saving to disk first, or if you are building a library that needs to minimize dependencies. For most application-level code, the higher-level wrappers provide better safety and developer experience.
Choose busboy if you need maximum control over the parsing process or are building a custom framework. It is a streaming parser that does not save files for you, so it is best suited for scenarios where you need to pipe data directly to cloud storage (like S3) or perform custom validation on every chunk. Avoid it for simple projects unless you need to minimize dependencies or require specific stream manipulation.
Choose formidable if you are working with raw Node.js http servers, Koa, or Hono, and need a reliable, feature-rich parser that handles file saving automatically. It is excellent for prototypes and applications where developer experience and straightforward configuration are more important than squeezing out every millisecond of performance. It is less suitable for Express apps where multer is the convention.
Choose multer if your project is built on Express.js. It is the community standard for this framework, offering extensive documentation, plugins, and middleware compatibility. It is the best fit for traditional server-rendered apps or REST APIs where files need to be stored locally on the server disk or in memory before further processing. Do not use it with Fastify or non-Express frameworks.
Choose @fastify/multipart if you are building an application with the Fastify framework. It is the only choice that fully integrates with Fastify's lifecycle, schema validation, and error handling. It is ideal for high-performance APIs where you need to process streams efficiently using async/await syntax without the overhead of callback-based middleware.
A node.js module for parsing incoming HTML form data.
Changes (breaking or otherwise) in v1.0.0 can be found here.
npm install busboy
const http = require('http');
const busboy = require('busboy');
http.createServer((req, res) => {
if (req.method === 'POST') {
console.log('POST request');
const bb = busboy({ headers: req.headers });
bb.on('file', (name, file, info) => {
const { filename, encoding, mimeType } = info;
console.log(
`File [${name}]: filename: %j, encoding: %j, mimeType: %j`,
filename,
encoding,
mimeType
);
file.on('data', (data) => {
console.log(`File [${name}] got ${data.length} bytes`);
}).on('close', () => {
console.log(`File [${name}] done`);
});
});
bb.on('field', (name, val, info) => {
console.log(`Field [${name}]: value: %j`, val);
});
bb.on('close', () => {
console.log('Done parsing form!');
res.writeHead(303, { Connection: 'close', Location: '/' });
res.end();
});
req.pipe(bb);
} else if (req.method === 'GET') {
res.writeHead(200, { Connection: 'close' });
res.end(`
<html>
<head></head>
<body>
<form method="POST" enctype="multipart/form-data">
<input type="file" name="filefield"><br />
<input type="text" name="textfield"><br />
<input type="submit">
</form>
</body>
</html>
`);
}
}).listen(8000, () => {
console.log('Listening for requests');
});
// Example output:
//
// Listening for requests
// < ... form submitted ... >
// POST request
// File [filefield]: filename: "logo.jpg", encoding: "binary", mime: "image/jpeg"
// File [filefield] got 11912 bytes
// Field [textfield]: value: "testing! :-)"
// File [filefield] done
// Done parsing form!
const { randomFillSync } = require('crypto');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const busboy = require('busboy');
const random = (() => {
const buf = Buffer.alloc(16);
return () => randomFillSync(buf).toString('hex');
})();
http.createServer((req, res) => {
if (req.method === 'POST') {
const bb = busboy({ headers: req.headers });
bb.on('file', (name, file, info) => {
const saveTo = path.join(os.tmpdir(), `busboy-upload-${random()}`);
file.pipe(fs.createWriteStream(saveTo));
});
bb.on('close', () => {
res.writeHead(200, { 'Connection': 'close' });
res.end(`That's all folks!`);
});
req.pipe(bb);
return;
}
res.writeHead(404);
res.end();
}).listen(8000, () => {
console.log('Listening for requests');
});
busboy exports a single function:
( function )(< object >config) - Creates and returns a new Writable form parser stream.
Valid config properties:
headers - object - These are the HTTP headers of the incoming request, which are used by individual parsers.
highWaterMark - integer - highWaterMark to use for the parser stream. Default: node's stream.Writable default.
fileHwm - integer - highWaterMark to use for individual file streams. Default: node's stream.Readable default.
defCharset - string - Default character set to use when one isn't defined. Default: 'utf8'.
defParamCharset - string - For multipart forms, the default character set to use for values of part header parameters (e.g. filename) that are not extended parameters (that contain an explicit charset). Default: 'latin1'.
preservePath - boolean - If paths in filenames from file parts in a 'multipart/form-data' request shall be preserved. Default: false.
limits - object - Various limits on incoming data. Valid properties are:
fieldNameSize - integer - Max field name size (in bytes). Default: 100.
fieldSize - integer - Max field value size (in bytes). Default: 1048576 (1MB).
fields - integer - Max number of non-file fields. Default: Infinity.
fileSize - integer - For multipart forms, the max file size (in bytes). Default: Infinity.
files - integer - For multipart forms, the max number of file fields. Default: Infinity.
parts - integer - For multipart forms, the max number of parts (fields + files). Default: Infinity.
headerPairs - integer - For multipart forms, the max number of header key-value pairs to parse. Default: 2000 (same as node's http module).
This function can throw exceptions if there is something wrong with the values in config. For example, if the Content-Type in headers is missing entirely, is not a supported type, or is missing the boundary for 'multipart/form-data' requests.
file(< string >name, < Readable >stream, < object >info) - Emitted for each new file found. name contains the form field name. stream is a Readable stream containing the file's data. No transformations/conversions (e.g. base64 to raw binary) are done on the file's data. info contains the following properties:
filename - string - If supplied, this contains the file's filename. WARNING: You should almost never use this value as-is (especially if you are using preservePath: true in your config) as it could contain malicious input. You are better off generating your own (safe) filenames, or at the very least using a hash of the filename.
encoding - string - The file's 'Content-Transfer-Encoding' value.
mimeType - string - The file's 'Content-Type' value.
Note: If you listen for this event, you should always consume the stream whether you care about its contents or not (you can simply do stream.resume(); if you want to discard/skip the contents), otherwise the 'finish'/'close' event will never fire on the busboy parser stream.
However, if you aren't accepting files, you can either simply not listen for the 'file' event at all or set limits.files to 0, and any/all files will be automatically skipped (these skipped files will still count towards any configured limits.files and limits.parts limits though).
Note: If a configured limits.fileSize limit was reached for a file, stream will both have a boolean property truncated set to true (best checked at the end of the stream) and emit a 'limit' event to notify you when this happens.
field(< string >name, < string >value, < object >info) - Emitted for each new non-file field found. name contains the form field name. value contains the string value of the field. info contains the following properties:
nameTruncated - boolean - Whether name was truncated or not (due to a configured limits.fieldNameSize limit)
valueTruncated - boolean - Whether value was truncated or not (due to a configured limits.fieldSize limit)
encoding - string - The field's 'Content-Transfer-Encoding' value.
mimeType - string - The field's 'Content-Type' value.
partsLimit() - Emitted when the configured limits.parts limit has been reached. No more 'file' or 'field' events will be emitted.
filesLimit() - Emitted when the configured limits.files limit has been reached. No more 'file' events will be emitted.
fieldsLimit() - Emitted when the configured limits.fields limit has been reached. No more 'field' events will be emitted.