How to Add File Uploads: Multer vs Formidable vs Busboy
·PkgPulse Team
TL;DR
Multer for Express apps, Busboy for streaming to S3 (no disk writes), Formidable for framework-agnostic parsing. Multer is the most popular (10M+ weekly downloads) and the easiest for basic Express file upload with disk storage. For production, stream directly to S3 with Busboy — never write files to disk on your servers. Both handle multipart/form-data; the difference is where files go and how much memory you use.
Key Takeaways
- Multer: Express middleware, disk or memory storage, simple API, 10M+ downloads
- Busboy: Streaming parser, no disk writes, pipe directly to S3 — fastest for large files
- Formidable: Framework-agnostic, works with any Node.js HTTP server or framework
- Size limits are critical — always set
limits.fileSizeto prevent DoS attacks - Type validation — check MIME type AND file extension (both can be spoofed; check both)
Option 1: Multer (Express)
npm install multer @types/multer
Disk Storage
// upload.ts
import multer from 'multer';
import path from 'path';
import crypto from 'crypto';
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/'); // Create this directory
},
filename: (req, file, cb) => {
// Random filename to prevent collisions and path traversal
const uniqueName = crypto.randomBytes(16).toString('hex');
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uniqueName}${ext}`);
},
});
const fileFilter = (req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type not allowed: ${file.mimetype}`));
}
};
export const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
files: 5, // Max 5 files per request
},
});
// routes.ts
import express from 'express';
import { upload } from './upload';
const router = express.Router();
// Single file
router.post('/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.json({
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
mimeType: req.file.mimetype,
path: `/uploads/${req.file.filename}`,
});
});
// Multiple files (same field)
router.post('/photos', upload.array('photos', 5), (req, res) => {
const files = req.files as Express.Multer.File[];
res.json({ uploaded: files.map(f => f.filename) });
});
// Multiple fields
router.post('/profile', upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'documents', maxCount: 3 },
]), (req, res) => {
const files = req.files as { [fieldname: string]: Express.Multer.File[] };
res.json({
avatar: files.avatar?.[0]?.filename,
documents: files.documents?.map(f => f.filename),
});
});
Memory Storage (for processing before saving)
const memoryUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB — careful with memory
});
router.post('/image-resize', memoryUpload.single('image'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
// Process in memory (e.g., with Sharp)
const resized = await sharp(req.file.buffer)
.resize(800, 600, { fit: 'inside' })
.webp({ quality: 80 })
.toBuffer();
// Then upload to S3
await s3.putObject({
Bucket: 'my-bucket',
Key: `images/${Date.now()}.webp`,
Body: resized,
ContentType: 'image/webp',
}).promise();
res.json({ success: true });
});
Option 2: Busboy (Stream Directly to S3)
npm install busboy @aws-sdk/client-s3 @aws-sdk/lib-storage
npm install -D @types/busboy
// Stream upload directly to S3 — never touches disk
import Busboy from 'busboy';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { PassThrough } from 'stream';
const s3 = new S3Client({ region: 'us-east-1' });
export async function handleUpload(req: Request): Promise<{ key: string; size: number }> {
return new Promise((resolve, reject) => {
const busboy = Busboy({
headers: req.headers as Record<string, string>,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
});
busboy.on('file', (fieldname, fileStream, info) => {
const { filename, mimeType } = info;
// Validate MIME type
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(mimeType)) {
fileStream.resume(); // Drain the stream
return reject(new Error(`Type not allowed: ${mimeType}`));
}
const key = `uploads/${Date.now()}-${filename}`;
const pass = new PassThrough();
// Stream: client → busboy → passthrough → S3
const upload = new Upload({
client: s3,
params: {
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: pass,
ContentType: mimeType,
},
});
let size = 0;
fileStream.on('data', (chunk) => { size += chunk.length; });
fileStream.pipe(pass);
upload.done()
.then(() => resolve({ key, size }))
.catch(reject);
});
busboy.on('error', reject);
req.pipe(busboy);
});
}
// Express route using busboy
router.post('/upload', (req, res) => {
handleUpload(req)
.then(result => res.json({
url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${result.key}`,
size: result.size,
}))
.catch(err => res.status(400).json({ error: err.message }));
});
Option 3: Formidable (Framework-Agnostic)
npm install formidable
npm install -D @types/formidable
// Works with any Node.js HTTP server
import formidable from 'formidable';
import { IncomingMessage } from 'http';
async function parseUpload(req: IncomingMessage) {
const form = formidable({
maxFileSize: 10 * 1024 * 1024, // 10MB
maxFiles: 3,
uploadDir: '/tmp/uploads',
keepExtensions: true,
filter: ({ mimetype }) => {
return !!mimetype && mimetype.includes('image');
},
});
const [fields, files] = await form.parse(req);
return { fields, files };
}
// Express
router.post('/upload', async (req, res) => {
try {
const { files } = await parseUpload(req);
const uploaded = files.file?.map(f => ({
name: f.originalFilename,
size: f.size,
path: f.filepath,
}));
res.json({ files: uploaded });
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
Frontend: Uploading Files
// React file upload with progress
function FileUpload() {
const [progress, setProgress] = useState(0);
const handleUpload = async (file: File) => {
const formData = new FormData();
formData.append('avatar', file);
// Use XMLHttpRequest for progress tracking
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100));
}
});
xhr.onload = () => {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
console.log('Uploaded:', result);
}
};
xhr.send(formData);
};
return (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
}}
/>
{progress > 0 && <progress value={progress} max={100} />}
</div>
);
}
Security Checklist
// Security best practices for file uploads
// 1. Size limits (prevent DoS)
limits: { fileSize: 10 * 1024 * 1024 } // Always set this
// 2. Type validation — check BOTH mimetype AND extension
const allowedTypes = ['image/jpeg', 'image/png'];
const allowedExts = ['.jpg', '.jpeg', '.png'];
const ext = path.extname(file.originalname).toLowerCase();
if (!allowedTypes.includes(file.mimetype) || !allowedExts.includes(ext)) {
throw new Error('Invalid file type');
}
// 3. Rename files — never use originalname as filename
// ❌ Bad: uploads/user-photo.jpg (predictable, path traversal risk)
// ✅ Good: uploads/a3f8b2c1d4e5.jpg (random hex)
// 4. Store outside web root (or use S3)
// ❌ /public/uploads/ (directly accessible via URL)
// ✅ /private/uploads/ (serve via API with auth check)
// ✅ S3 with pre-signed URLs (best for production)
// 5. Virus scanning for production (ClamAV or VirusTotal API)
// 6. Image re-encoding strips EXIF and hidden payloads
// sharp(buffer).jpeg({ quality: 90 }).toBuffer() ← strips metadata
When to Choose
| Scenario | Pick | Reason |
|---|---|---|
| Express app, files to disk | Multer | Simple, well-documented |
| Streaming to S3, no disk | Busboy | Lowest memory, fastest |
| Non-Express framework | Formidable | Framework agnostic |
| Memory processing (resize/crop) | Multer memoryStorage | Buffer in req.file.buffer |
| Large files (>50MB) | Busboy | Streaming prevents memory spikes |
| Multiple fields + files | Multer | upload.fields() is clean |
Compare file upload package health on PkgPulse.
See the live comparison
View multer vs. formidable on PkgPulse →