Skip to main content

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.fileSize to 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

ScenarioPickReason
Express app, files to diskMulterSimple, well-documented
Streaming to S3, no diskBusboyLowest memory, fastest
Non-Express frameworkFormidableFramework agnostic
Memory processing (resize/crop)Multer memoryStorageBuffer in req.file.buffer
Large files (>50MB)BusboyStreaming prevents memory spikes
Multiple fields + filesMulterupload.fields() is clean

Compare file upload package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.