File Uploads

Express.js Intermediate — File Uploads

Express.js Intermediate/Advanced

Chapter 3 of 8  ·  File Uploads

File Uploads

File uploads use Content-Type: multipart/form-data — a completely different encoding from JSON. express.json() ignores it, so you need dedicated middleware. multer is the standard choice: it parses multipart requests, runs your validation callbacks, and either buffers the file in memory or writes it to disk before your route handler runs. The three things to nail: never trust the client's filename, always validate MIME type server-side, and always handle MulterError separately from application errors.

The Two Storage Engines

memoryStorage()

File is buffered entirely in RAM as a Buffer on req.file.buffer.

✓ Good for: transient processing — resize, validate, then stream to cloud storage (S3, GCS).

✗ Bad for: large files or high concurrency — RAM fills up fast. No disk I/O fallback.

diskStorage()

File is streamed to disk as it arrives. req.file.path is the saved path.

✓ Good for: files that stay on this server long-term; large files that would exhaust RAM.

✗ Bad for: multi-server deployments — each server has its own disk, no shared access.

Memory Storage

// npm install multer const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); // Single file: field name must match the HTML input name / form-data key app.post('/upload', upload.single('avatar'), (req, res) => { // req.file is undefined if no file was sent if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); console.log(req.file.originalname); // client-supplied filename — do NOT use for saving console.log(req.file.mimetype); // 'image/jpeg', 'application/pdf', etc. console.log(req.file.size); // bytes console.log(req.file.buffer); // Buffer — the raw file bytes res.json({ size: req.file.size }); });

Disk Storage

const path = require('node:path'); const crypto = require('node:crypto'); const multer = require('multer'); const storage = multer.diskStorage({ destination(req, file, cb) { cb(null, './uploads'); // directory must exist — multer won't create it }, filename(req, file, cb) { // NEVER use file.originalname — it's client-controlled // Generate a random name, keep only the extension const ext = path.extname(file.originalname).toLowerCase(); const safeName = crypto.randomBytes(16).toString('hex') + ext; cb(null, safeName); // e.g. 'a3f8c2d1...b9e4.jpg' }, }); const upload = multer({ storage }); app.post('/upload', upload.single('photo'), (req, res) => { console.log(req.file.filename); // the name we generated (safe) console.log(req.file.path); // './uploads/a3f8c2d1...b9e4.jpg' console.log(req.file.destination); // './uploads' res.json({ filename: req.file.filename }); });

Handling Single vs Multiple Files

Methodreq.file / req.filesUse when
upload.single('name')req.file — one object or undefinedOne file from one field (avatar, document)
upload.array('name', max)req.files — array, same fieldMultiple files from one field (gallery, attachments)
upload.fields([{name, maxCount}])req.files — object keyed by field nameDifferent files from different fields (avatar + gallery)
upload.none()Neither — text fields onlyMultipart form with no file fields
// Multiple files — same field app.post('/gallery', upload.array('images', 5), (req, res) => { // req.files → [{ fieldname, originalname, size, ... }, ...] res.json({ count: req.files.length }); }); // Different files from different fields app.post('/profile', upload.fields([ { name: 'avatar', maxCount: 1 }, { name: 'gallery', maxCount: 4 }, ]), (req, res) => { const avatar = req.files.avatar?.[0]; // single file object const gallery = req.files.gallery ?? []; // array (may be absent) res.json({ avatar: avatar?.filename, galleryCount: gallery.length }); });

Validation: fileFilter and limits

const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']); const ALLOWED_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp']); const upload = multer({ storage: multer.diskStorage({ /* ... */ }), limits: { fileSize: 5 * 1024 * 1024, // 5 MB — enforced during streaming, before route runs files: 5, // max number of files per request fieldSize: 1 * 1024 * 1024, // max size of non-file fields (1 MB) }, fileFilter(req, file, cb) { // cb(error) → reject with an error (route sees it as next(err)) // cb(null, true) → accept the file // cb(null, false) → silently skip (req.file will be undefined) const ext = path.extname(file.originalname).toLowerCase(); if (!ALLOWED_MIME.has(file.mimetype) || !ALLOWED_EXT.has(ext)) { // Use a custom error so the error handler can map it to 415 const err = new Error('Only JPEG, PNG and WebP images are allowed'); err.status = 415; return cb(err); } cb(null, true); }, });

MIME type vs extension — check both

MIME type (file.mimetype) comes from the client's Content-Type header for that part — trivial to spoof. File extension (path.extname(file.originalname)) is also client-supplied — also trivial to spoof. Neither alone is sufficient. Checking both raises the bar: an attacker must get two things right instead of one. For critical security (e.g. preventing SVG XSS), also read the first few bytes of the file and check the magic bytes signature server-side.

Handling MulterError

When multer hits a limit or an unexpected field, it throws a MulterError (a subclass of Error) and calls next(err). These errors are not application errors — they need their own handling branch in the error middleware, otherwise they all become 500s.

const multer = require('multer'); // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { const statusMap = { LIMIT_FILE_SIZE: 413, // Payload Too Large LIMIT_FILE_COUNT: 400, // too many files LIMIT_UNEXPECTED_FILE:400, // wrong field name LIMIT_FIELD_COUNT: 400, LIMIT_FIELD_KEY_SIZE: 400, LIMIT_FIELD_VALUE_SIZE:413, }; const status = statusMap[err.code] ?? 400; const message = err.code === 'LIMIT_FILE_SIZE' ? `File too large (max 5 MB)` : err.message; return res.status(status).json({ error: message, code: err.code }); } // fileFilter errors (status set by us) and other app errors const status = err.status ?? 500; const message = status < 500 ? err.message : 'Internal server error'; res.status(status).json({ error: message }); });
MulterError.codeMeaningSuggested status
LIMIT_FILE_SIZEFile exceeds the fileSize limit413
LIMIT_FILE_COUNTMore files than the files limit400
LIMIT_UNEXPECTED_FILEField name not in the accepted list400
LIMIT_FIELD_COUNTToo many non-file fields400
LIMIT_FIELD_VALUE_SIZENon-file field exceeds fieldSize413

Storage Strategies

Local Disk

Use diskStorage(). Serve with express.static('./uploads').

✓ Zero dependencies, simple setup, fast reads.

✗ Doesn't scale past one server. Files lost if server is replaced.

Use for: single-server apps, dev environments.

Memory → Cloud

Use memoryStorage(). In the route, stream req.file.buffer to S3/GCS/Azure Blob.

✓ Files accessible from any server. Durable, scalable.

✗ Extra round-trip latency. RAM spikes under load.

Use for: production multi-server apps.

Disk → Cloud (temp)

Use diskStorage() as a temp buffer, then stream the file to cloud and delete the temp file.

✓ Handles files too large to fit in RAM.

✗ Must clean up temp files on success AND error paths.

Use for: large file uploads (video, archives).

Memory → S3 Pattern

// npm install @aws-sdk/client-s3 const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); const multer = require('multer'); const crypto = require('node:crypto'); const path = require('node:path'); const s3 = new S3Client({ region: process.env.AWS_REGION }); const BUCKET = process.env.S3_BUCKET; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, }); app.post('/upload', upload.single('photo'), async (req, res) => { if (!req.file) throw createError(400, 'No file uploaded'); const ext = path.extname(req.file.originalname).toLowerCase(); const key = `uploads/${crypto.randomBytes(16).toString('hex')}` + ext; await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: req.file.buffer, // the in-memory bytes ContentType: req.file.mimetype, })); const url = `https://${BUCKET}.s3.amazonaws.com/${key}`; res.status(201).json({ url }); });

🔒 Upload Security Checklist

Never use file.originalname as the saved filename. A client can send ../../../etc/passwd as the filename — using it directly is a path traversal vulnerability. Always generate a random name.

Never serve uploads from inside your app directory. If a malicious .html or .js file is uploaded and served from the same origin as your app, it can run with your app's cookies and permissions. Serve uploads from a separate subdomain, a CDN, or a storage bucket with its own domain.

Validate MIME type server-side. The Content-Type in the multipart request is supplied by the client — never trust it alone. Check magic bytes for critical file types.

Set limits.fileSize always. Without it, a malicious client can stream an unlimited file, exhausting disk or RAM.

Quick Reference

APINotes
multer({ storage, limits, fileFilter })Create an upload middleware instance. Reuse it across routes.
upload.single('field')Accepts one file from field 'field'. Puts it on req.file.
upload.array('field', max)Accepts up to max files from one field. Result in req.files.
upload.fields([...])Multiple field definitions. Result in req.files keyed by field name.
req.file.bufferRaw bytes (memoryStorage only). Use to stream to S3 or process in memory.
req.file.pathFull path to the saved file (diskStorage only).
req.file.mimetypeClient-supplied MIME type. Validate but do not trust alone.
req.file.sizeBytes. Available after the file is received.
err instanceof multer.MulterErrorTrue for limit violations. Check err.code for the specific limit.
limits.fileSizeBytes. Enforced by multer during streaming — the route handler never runs for oversized files.

Coding Challenges

Challenge 1 — Single File Upload with Disk Storage

Build a POST /upload/avatar route using diskStorage() that saves uploaded files to an uploads/ directory. Generate a random hex filename (preserving the original extension). Apply a fileFilter that accepts only JPEG, PNG, and WebP images (validate both MIME type and extension; reject with status 415). Set a 2 MB fileSize limit. The route should return { filename, size, mimetype } on success. Add a GET /uploads/:filename route that serves the file using res.sendFile() with an absolute path. Write supertest tests using a real small JPEG buffer (construct a minimal valid JPEG: Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, ...]) or use a fixture file): 200 with a JSON body for a valid upload; 415 for a .txt file; 413 for a file exceeding 2 MB; 400 when no file is sent; the returned filename is NOT the original filename; a GET to /uploads/:filename with the returned filename returns 200.

View sample solution ↗

Challenge 2 — Multiple Fields with Different Validation Rules

Build a POST /upload/profile route using upload.fields() that accepts two fields: avatar (max 1 file, images only, 2 MB limit) and documents (max 3 files, PDFs only, 5 MB each). Since multer applies one fileFilter to all fields, inspect file.fieldname inside the filter to apply field-specific MIME type rules. After the upload middleware, validate in the route handler that at least one field was provided (reject 400 if both are absent). Return { avatar: { filename, size } | null, documents: [{ filename, size }] }. Write supertest tests: 400 when no files are sent; valid avatar upload succeeds; valid documents upload (up to 3 PDFs) succeeds; avatar field rejects a PDF (415); documents field rejects a JPEG (415); uploading 4 documents triggers LIMIT_UNEXPECTED_FILE or LIMIT_FILE_COUNT (400).

View sample solution ↗

Challenge 3 — Memory Storage with Mock Cloud Upload

Build a POST /upload/photo route using memoryStorage(). After multer processes the file, pass req.file.buffer, req.file.mimetype, and a generated key to a uploadToCloud(key, buffer, mimeType) function (write this as a separate module so it can be mocked in tests). The function should return a { url } object. If the cloud upload fails, delete nothing (memory storage has no disk file to clean up) and propagate a 502 error. Return 201 { url, size } on success. Write tests using jest.mock() to replace the cloud upload module: mock resolves → 201 with url and size; mock rejects → route returns 502; no file sent → 400 before cloud upload is even called (assert the mock was NOT called); file exceeds 3 MB limit → 413 and mock not called. Use jest.clearAllMocks() in beforeEach.

View sample solution ↗