Skip to main content

How to handle file uploads in Express.js with Multer?

Multer is Node.js middleware for Express that parses multipart/form-data requests, turning raw binary streams into req.file or req.files objects your route handlers can actually use.

Theory

TL;DR

  • Multer acts like a mailroom clerk: it opens the multipart request, separates form fields from file data, and hands both to your route handler
  • Express parses JSON and URL-encoded bodies by default but ignores file uploads entirely. Multer fills that gap
  • upload.single('field') for one file, upload.array('field', n) for multiple from the same field, upload.fields([...]) for mixed
  • memoryStorage() keeps files in RAM as a Buffer, diskStorage() writes to disk. Pick based on what you do next with the file
  • Always set limits and fileFilter in production. Without them, anyone can send a 10GB "image" to your server

Quick example

js
const express = require('express'); const multer = require('multer'); const app = express(); const upload = multer({ storage: multer.memoryStorage() }); app.post('/upload', upload.single('avatar'), (req, res) => { // req.file = { fieldname, originalname, mimetype, size, buffer } res.json({ message: 'File received', size: req.file.size }); }); app.listen(3000); // POST /upload with form-data field "avatar" = photo.jpg // req.file.buffer is ready for processing or sending to S3

upload.single('avatar') is middleware. It runs before your handler, parses the stream, and populates req.file. Your handler just reads the result.

Why Express alone won't work

Express parses application/json and application/x-www-form-urlencoded bodies natively. But file uploads arrive as multipart/form-data, a single binary stream split by boundary markers in the Content-Type header. Express ignores this stream entirely.

Multer hooks into the middleware chain, reads the raw stream with Busboy under the hood, splits it at those boundaries, buffers file chunks into Buffer objects, and decodes text fields. After Multer runs, your route has req.body for fields and req.file for the file.

Storage options

Two built-in options:

multer.memoryStorage() holds the file in RAM as req.file.buffer. Good for processing immediately: resize with sharp, upload to S3, run OCR. Bad for large files or high traffic. 100 users uploading 50MB each equals a 5GB RAM spike.

multer.diskStorage({ destination, filename }) writes directly to disk. Good for files you serve statically or process later. You control the path and filename via callbacks.

js
const path = require('path'); const { v4: uuidv4 } = require('uuid'); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, 'public/uploads/'), filename: (req, file, cb) => { const ext = path.extname(file.originalname); cb(null, `${uuidv4()}${ext}`); // e.g. 550e8400-e29b-41d4-a716-446655440001.jpg } });

Using file.originalname directly as the filename causes collisions. Two users uploading avatar.jpg overwrite each other. UUID or Date.now() + random fixes this.

When to use each method

  • One file from one form field: upload.single('fieldName')
  • Several files from the same field: upload.array('photos', 5) (second arg is max count)
  • Files from different fields: upload.fields([{name: 'avatar', maxCount: 1}, {name: 'docs', maxCount: 3}])
  • No files, just form fields: skip Multer, use express.urlencoded()
  • Process in memory then push to S3: memoryStorage()
  • Serve files from disk: diskStorage()

Validation and limits

Without limits, Multer buffers whatever comes in. A limits object and a fileFilter function cover the two main attack vectors:

js
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, // 5MB max per file files: 5 // max 5 files per request }, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only images allowed'), false); } } });

fileFilter runs before the file is buffered. Passing false rejects the file. Passing an Error sends it downstream as an error you catch with error-handling middleware.

One thing to know: mimetype comes from the client's Content-Type header. An attacker can send image/jpeg for a PHP file. For real security, check magic bytes in the buffer. JPEG starts with 0xFF 0xD8, PNG with 0x89 0x50 0x4E 0x47. Read req.file.buffer.slice(0, 4) and compare.

Error handling

Multer throws two kinds of errors: multer.MulterError for built-in limits and regular Error for custom rejections from fileFilter. Handle both in one middleware:

js
app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'File too large (max 5MB)' }); } return res.status(400).json({ error: err.message }); } if (err.message === 'Only images allowed') { return res.status(400).json({ error: err.message }); } next(err); });

If you use diskStorage and a file saves to disk but the request later fails, clean it up with fs.unlink(req.file.path, () => {}).

Common mistakes

Forgetting enctype="multipart/form-data" on the HTML form

Without it, the browser sends application/x-www-form-urlencoded. Multer skips it, req.file is undefined, and your handler crashes on req.file.originalname.

html
<!-- breaks with no error indication --> <form action="/upload" method="post"> <input type="file" name="avatar"> </form> <!-- correct --> <form action="/upload" method="post" enctype="multipart/form-data"> <input type="file" name="avatar"> </form>

No limits in production

A 2GB file sent to an endpoint with memoryStorage() and no fileSize limit gets buffered entirely in RAM. Node dies. Add limits: { fileSize: 10 * 1024 * 1024 } at minimum.

Using cb(null, false) in fileFilter without throwing

When fileFilter calls cb(null, false), the file is rejected quietly. req.file is undefined. Your handler then crashes reading req.file.size. I've seen this burn teams for an hour debugging why uploads disappeared without any error. Throw instead: cb(new Error('Invalid file type'), false).

Storing original filename on disk

cb(null, file.originalname) causes collisions. Ten users uploading photo.jpg all overwrite the same file. Use uuidv4() or Date.now() + '-' + Math.round(Math.random() * 1e9).

Using memoryStorage for files you just write to disk anyway

If your handler does fs.writeFile('uploads/' + file.originalname, req.file.buffer), you buffered the file in RAM first, then wrote it. Use diskStorage instead. Multer streams it directly to disk and skips the RAM allocation entirely.

Real-world usage

  • Profile picture APIs: memoryStorage() then s3.putObject({ Body: req.file.buffer })
  • NestJS: @UseInterceptors(FileInterceptor('file')) wraps Multer, same behavior underneath
  • Strapi CMS: uses diskStorage for its media library uploads
  • Image processing pipelines: memoryStorage() then sharp(req.file.buffer).resize(200, 200)

Formidable is an alternative for raw Node.js without Express dependency, with faster stream parsing. Busboy is what Multer uses internally. For maximum throughput when streaming a 1GB file directly to S3 without buffering, drop down to Busboy or @aws-sdk/lib-storage directly.

Follow-up questions

Q: What is the difference between upload.single(), upload.array(), and upload.fields()?
A: single('avatar') expects one file in the named field and puts it in req.file. array('photos', 5) takes multiple files from the same field and puts them in req.files as an array. fields([{name:'avatar'}, {name:'docs'}]) handles files from different field names and puts them in req.files as an object keyed by field name.

Q: How do you handle large file uploads without crashing Node?
A: memoryStorage buffers the whole file in RAM. For anything over a few MB at scale, use diskStorage so Multer streams directly to disk. For S3, build a stream pipeline with @aws-sdk/lib-storage Upload instead of buffering the entire file first.

Q: How do you validate the actual file type, not just the MIME type?
A: Check magic bytes in the buffer. JPEG starts with 0xFF 0xD8, PNG with 0x89 0x50 0x4E 0x47. Read req.file.buffer.slice(0, 4) and compare. The mimetype field comes from the client header and can be spoofed freely.

Q: What happens if a file passes fileFilter but disk storage fails mid-write?
A: Multer may have partially written the file. Check req.file.path in your error handler and call fs.unlink(req.file.path, () => {}) to clean it up. A try/finally block in your handler works well here.

Q: Why does Multer double-buffer when using memoryStorage with S3?
A: Multer collects all incoming chunks into one Buffer via Busboy. Then you create a Readable stream from that buffer to pass to s3.putObject. That is two copies in memory. To avoid it, skip Multer for large S3 uploads and pipe req directly through @aws-sdk/lib-storage Upload with streaming enabled.

Examples

Basic: single file upload with memory storage

js
const express = require('express'); const multer = require('multer'); const app = express(); const upload = multer({ storage: multer.memoryStorage() }); app.post('/api/avatar', upload.single('avatar'), (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } // req.file.buffer holds the raw bytes // req.file.mimetype = 'image/jpeg' // req.file.size = 45678 res.json({ name: req.file.originalname, size: req.file.size, type: req.file.mimetype }); }); app.listen(3000); // curl -F "avatar=@photo.jpg" http://localhost:3000/api/avatar

upload.single('avatar') runs as middleware before your handler. If the form field name doesn't match 'avatar', req.file is undefined. Always guard with if (!req.file).

Intermediate: avatar upload with disk storage, validation, and error handling

js
const path = require('path'); const { v4: uuidv4 } = require('uuid'); const multer = require('multer'); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, 'public/uploads/avatars/'), filename: (req, file, cb) => { const ext = path.extname(file.originalname); cb(null, `avatar-${uuidv4()}${ext}`); // e.g. avatar-550e8400-e29b-41d4-a716-446655440001.jpg } }); const upload = multer({ storage, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only images allowed'), false); } }, limits: { fileSize: 5 * 1024 * 1024 } // 5MB }); app.post('/api/users/avatar', upload.single('avatar'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file' }); res.json({ avatarUrl: `/uploads/avatars/${req.file.filename}` }); }); app.use((err, req, res, next) => { if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'Max file size is 5MB' }); } if (err.message === 'Only images allowed') { return res.status(400).json({ error: err.message }); } next(err); });

UUID filenames prevent collisions when multiple users upload files with the same name. The error-handling middleware at the end catches both Multer built-in errors and custom ones from fileFilter. Without it, fileFilter errors surface as unhandled 500s.

Advanced: file upload directly to S3 from memory

js
const multer = require('multer'); const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, // 10MB fileFilter: (req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'image/webp']; if (allowed.includes(file.mimetype)) cb(null, true); else cb(new Error('Unsupported format'), false); } }); const s3 = new S3Client({ region: 'us-east-1' }); app.post('/api/photos', upload.single('photo'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file' }); const key = `photos/${Date.now()}-${req.file.originalname}`; await s3.send(new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, Body: req.file.buffer, // buffer from memoryStorage ContentType: req.file.mimetype })); res.json({ url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}` }); });

memoryStorage is the right choice here because the buffer is needed to send to S3 and the file never touches disk. For files larger than 50MB, switch to @aws-sdk/lib-storage Upload with streaming. It handles multipart S3 upload without holding the entire file in RAM.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?