5 Ways to Handle File Uploads in Node.js (Multer, Busboy, S3)
Compare 5 Node.js file upload methods — Multer, Busboy, S3 direct upload, memory storage, and disk storage — with code examples, a comparison table, and validation.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
File uploads seem simple until you're in production and a user uploads a 4GB video file that brings your Node.js server to its knees, or you discover that your "image upload" endpoint happily accepts executable files because you only checked the file extension.
I've made both of those mistakes. Here's what I know now.
Understanding Multipart Form Data
Before any library, it helps to understand what's actually happening. When a browser submits a file upload, it sends a multipart/form-data request — a request body divided into "parts" separated by a boundary string. Each part has headers (including Content-Disposition and Content-Type) and a body (the file data or field value).
Node.js's built-in HTTP server doesn't parse multipart data — you need a library. The five approaches we'll cover differ in where they sit in the pipeline and what they do with the file data.
Method 1: Multer with Disk Storage
Multer is the most widely used file upload middleware for Express. Disk storage saves files to the local filesystem, which is the right choice for smaller-scale applications that don't need cloud storage.
// Node.js + Express — Multer disk storage
const express = require('express');
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
const app = express();
// Ensure upload directory exists
const uploadDir = path.join(__dirname, '../uploads');
fs.mkdirSync(uploadDir, { recursive: true });
const diskStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// Use random UUID + original extension — never trust client-provided filenames
const ext = path.extname(file.originalname).toLowerCase();
const uniqueName = `${crypto.randomUUID()}${ext}`;
cb(null, uniqueName);
},
});
// File type validation
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const imageFilter = (req, file, cb) => {
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Invalid file type: ${file.mimetype}`), false);
}
};
const upload = multer({
storage: diskStorage,
fileFilter: imageFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5, // Max 5 files per request
},
});
// Single file upload
app.post('/api/upload/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
res.json({
filename: req.file.filename,
size: req.file.size,
url: `/uploads/${req.file.filename}`,
});
});
// Multiple files
app.post('/api/upload/photos', upload.array('photos', 10), (req, res) => {
if (!req.files?.length) {
return res.status(400).json({ error: 'No files uploaded' });
}
const files = (req.files as Express.Multer.File[]).map(f => ({
filename: f.filename,
size: f.size,
url: `/uploads/${f.filename}`,
}));
res.json({ files });
});
// Error handling for Multer errors
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large. Maximum 5MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum 10.' });
}
}
if (err.message.startsWith('Invalid file type')) {
return res.status(415).json({ error: err.message });
}
next(err);
});
Method 2: Multer with Memory Storage
Memory storage keeps the file in a Buffer in RAM rather than writing to disk. Useful when you need to process the file (resize, validate content) before deciding where to store it permanently.
const memoryStorage = multer.memoryStorage();
const uploadToMemory = multer({
storage: memoryStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB — keep this small for memory storage
});
const sharp = require('sharp');
app.post('/api/upload/avatar/resize', uploadToMemory.single('avatar'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
try {
// Process image in memory before saving
const resized = await sharp(req.file.buffer)
.resize(400, 400, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer();
const filename = `${crypto.randomUUID()}.webp`;
const outputPath = path.join(uploadDir, filename);
await fs.promises.writeFile(outputPath, resized);
res.json({
filename,
originalSize: req.file.size,
processedSize: resized.length,
url: `/uploads/${filename}`,
});
} catch (err) {
console.error('Image processing error:', err);
res.status(500).json({ error: 'Image processing failed' });
}
});
Important caveat: Memory storage keeps the entire file in RAM until your handler finishes. For concurrent uploads under load, this can exhaust available memory quickly. Keep the fileSize limit much lower for memory storage than for disk storage.
Method 3: Busboy for Streaming
Busboy is the lower-level library that Multer itself is built on. It streams file data as it arrives rather than buffering it, which makes it memory-efficient for large files. The API is more verbose but gives you complete control.
const Busboy = require('busboy');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
app.post('/api/upload/large', (req, res) => {
const contentType = req.headers['content-type'];
if (!contentType?.includes('multipart/form-data')) {
return res.status(400).json({ error: 'Expected multipart/form-data' });
}
const busboy = Busboy({
headers: req.headers,
limits: {
fileSize: 500 * 1024 * 1024, // 500MB — suitable for large files
files: 1,
},
});
const uploadedFiles = [];
let formFields = {};
busboy.on('field', (name, value) => {
formFields[name] = value;
});
busboy.on('file', (fieldname, fileStream, { filename, mimeType }) => {
const ext = path.extname(filename).toLowerCase();
const safeName = `${crypto.randomUUID()}${ext}`;
const filePath = path.join(uploadDir, safeName);
const writeStream = fs.createWriteStream(filePath);
let fileSize = 0;
fileStream.on('data', (chunk) => {
fileSize += chunk.length;
});
// Check if file was truncated (exceeded size limit)
fileStream.on('limit', () => {
writeStream.destroy();
fs.unlink(filePath, () => {}); // Clean up partial file
res.status(413).json({ error: 'File size limit exceeded' });
});
fileStream.pipe(writeStream);
writeStream.on('finish', () => {
uploadedFiles.push({ filename: safeName, size: fileSize, mimeType });
});
writeStream.on('error', (err) => {
console.error('Write error:', err);
res.status(500).json({ error: 'Upload failed' });
});
});
busboy.on('finish', () => {
if (!res.headersSent) {
res.json({ files: uploadedFiles, fields: formFields });
}
});
busboy.on('error', (err) => {
console.error('Busboy error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Upload processing failed' });
}
});
req.pipe(busboy);
});
Busboy's streaming approach is critical for large file uploads. The file data flows from the request directly to disk without loading it into memory — your Node.js process's memory stays flat regardless of file size.
Method 4: Direct S3 Upload via Pre-signed URLs
For production applications, storing files on S3 (or compatible storage) is usually the right choice. But there are two architectures:
- Server-side upload: Client uploads to your server, server uploads to S3 (two hops, more server bandwidth/memory)
- Pre-signed URL upload: Server generates a signed URL, client uploads directly to S3 (one hop, no server bandwidth)
The pre-signed URL approach is better for most cases:
// AWS SDK v3
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const crypto = require('crypto');
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
const BUCKET = process.env.S3_BUCKET;
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Step 1: Client requests a pre-signed upload URL
app.post('/api/upload/presign', authenticate, async (req, res) => {
const { fileType, fileSize, fileName } = req.body;
if (!ALLOWED_TYPES.includes(fileType)) {
return res.status(415).json({ error: 'File type not allowed' });
}
if (fileSize > MAX_FILE_SIZE) {
return res.status(413).json({ error: 'File too large' });
}
const key = `uploads/${req.user.userId}/${crypto.randomUUID()}`;
const ext = fileName.split('.').pop()?.toLowerCase();
const s3Key = `${key}.${ext}`;
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: s3Key,
ContentType: fileType,
ContentLength: fileSize,
// Metadata stored in S3
Metadata: {
'uploaded-by': String(req.user.userId),
'original-name': encodeURIComponent(fileName),
},
});
// URL expires in 5 minutes
const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
// Save a pending record in your database
await db.query(
`INSERT INTO uploads (user_id, s3_key, status, created_at)
VALUES ($1, $2, 'pending', NOW())`,
[req.user.userId, s3Key]
);
res.json({ uploadUrl: presignedUrl, key: s3Key });
});
// Step 2: After upload, client confirms the upload succeeded
app.post('/api/upload/confirm', authenticate, async (req, res) => {
const { key } = req.body;
// Generate a CDN/download URL
const downloadCommand = new GetObjectCommand({ Bucket: BUCKET, Key: key });
const downloadUrl = await getSignedUrl(s3, downloadCommand, { expiresIn: 3600 });
await db.query(
`UPDATE uploads SET status = 'complete', confirmed_at = NOW()
WHERE s3_key = $1 AND user_id = $2`,
[key, req.user.userId]
);
res.json({ downloadUrl, key });
});
The browser-side upload with the pre-signed URL:
// Client-side — upload directly to S3
async function uploadFile(file) {
// Get pre-signed URL from your API
const { uploadUrl, key } = await api.post('/api/upload/presign', {
fileType: file.type,
fileSize: file.size,
fileName: file.name,
});
// Upload directly to S3 — no server bandwidth used
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
// Confirm upload with your API
const { downloadUrl } = await api.post('/api/upload/confirm', { key });
return downloadUrl;
}
Method 5: Multer with S3 via multer-s3
For simpler S3 integration without the two-step pre-signed URL flow:
const multerS3 = require('multer-s3');
const s3Upload = multer({
storage: multerS3({
s3,
bucket: BUCKET,
contentType: multerS3.AUTO_CONTENT_TYPE,
key: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `uploads/${req.user.userId}/${crypto.randomUUID()}${ext}`);
},
metadata: (req, file, cb) => {
cb(null, { fieldName: file.fieldname });
},
}),
fileFilter: imageFilter,
limits: { fileSize: 10 * 1024 * 1024 },
});
app.post('/api/upload/s3', authenticate, s3Upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
res.json({
key: (req.file as any).key,
location: (req.file as any).location, // S3 URL
});
});
Method Comparison Table
| Method | Storage | Memory Usage | Max File Size | Streaming | Complexity | Best For |
|---|---|---|---|---|---|---|
| Multer + Disk | Local filesystem | Low | Limited by disk | No | Low | Small-medium apps, single server |
| Multer + Memory | RAM | High | ~50MB practical | No | Low | Processing before storage |
| Busboy + Disk | Local filesystem | Very Low | Any size | Yes | Medium | Large files, custom processing |
| S3 Pre-signed URL | AWS S3 | None (server) | Any size | N/A | Medium | Production, scale, CDN delivery |
| Multer + multer-s3 | AWS S3 | Low | ~100MB | Partial | Low | Production with simpler setup |
File Validation Beyond MIME Type
Client-provided MIME types can be spoofed. To reliably validate file types, check the actual file signature (magic bytes):
const fileType = require('file-type');
async function validateFileSignature(buffer, expectedTypes) {
const detected = await fileType.fromBuffer(buffer);
if (!detected) return false;
return expectedTypes.includes(detected.mime);
}
// In your upload handler (with memory storage)
app.post('/api/upload/safe', uploadToMemory.single('file'), async (req, res) => {
const isValid = await validateFileSignature(
req.file.buffer,
['image/jpeg', 'image/png', 'image/webp']
);
if (!isValid) {
return res.status(415).json({
error: 'File content does not match an allowed image type'
});
}
// Proceed with storage...
});
A file named malicious.png that is actually an executable will fail this check, whereas checking req.file.mimetype would have accepted it.
Serving Uploaded Files
If you're using disk storage, you need to serve those files:
// Serve uploads with proper headers
app.use('/uploads', express.static(uploadDir, {
setHeaders: (res, filePath) => {
// Prevent uploaded files from being executed as scripts
res.set('X-Content-Type-Options', 'nosniff');
res.set('Content-Disposition', 'inline');
// Cache static assets
if (filePath.match(/\.(jpg|jpeg|png|webp|gif)$/)) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
},
}));
For the security implications of file upload handling alongside your API security patterns, see our API security vulnerabilities guide.
Wrapping Up
The right file upload method depends on your use case and scale. For most production applications, I'd recommend the pre-signed S3 URL approach — it offloads bandwidth from your API servers, integrates naturally with CDN delivery, and scales without any server-side changes.
For development or smaller-scale applications, Multer with disk storage is the fastest path to a working implementation. The API is clean, error handling is straightforward, and it's easy to migrate to S3 later by swapping the storage engine.
Whatever method you use, always validate file signatures (not just extensions), randomize stored filenames, and set strict size limits. These three practices prevent the most common upload-related security issues.
For the broader Node.js performance picture, our Node.js performance tips guide covers how streaming patterns (like Busboy) fit into the larger Node.js performance model.
Frequently Asked Questions
Should I use Multer or Busboy for file uploads?
Multer is the better choice for most applications. It's built on Busboy but adds a much friendlier API with middleware integration, storage engine abstraction, and file validation. Use Busboy directly when you need maximum control over the streaming pipeline or when you're integrating file uploads into a larger custom stream processing workflow.
What is the maximum file size I should allow for uploads?
It depends on your use case. Profile photos: 5–10MB. Document uploads: 10–50MB. Video uploads: 100MB–2GB. Always enforce a server-side limit regardless of what the frontend validates — clients can bypass frontend limits. For large files, consider pre-signed S3 URLs to upload directly from the browser, bypassing your server entirely.
How do I handle file upload security?
Validate file type by checking the actual file signature (magic bytes), not just the extension or MIME type sent by the client. Rename uploaded files to random UUIDs to prevent path traversal. Scan files for malware if they'll be shared with other users. Store user uploads outside your web root or in object storage (S3), never in your application's directory.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How to Use Docker Compose for Local Dev (Node.js + PostgreSQL)
Set up a full local dev environment with Docker Compose, Node.js, PostgreSQL, and pgAdmin. Includes .env config, named volumes, healthchecks, and common error fixes.
5 GraphQL Resolver Best Practices (DataLoader, Error Handling)
Write efficient GraphQL resolvers that don't hammer your database. DataLoader N+1 fix, error handling patterns, auth in context, and resolver performance comparison.
7 Common API Security Vulnerabilities (and How to Fix Them)
Real API security vulnerabilities from the OWASP API Top 10 — with working code fixes, risk levels, and testing tools so you can protect your APIs today.
How to Deploy a Node.js App on Kubernetes With Minikube (2026)
Step-by-step guide to deploying a Node.js application on Kubernetes using Minikube in 2026. Covers Dockerfile, Deployment YAML, Service config, and exposing your app.