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
Disk Storage
Handling Single vs Multiple Files
| Method | req.file / req.files | Use when |
|---|---|---|
| upload.single('name') | req.file — one object or undefined | One file from one field (avatar, document) |
| upload.array('name', max) | req.files — array, same field | Multiple files from one field (gallery, attachments) |
| upload.fields([{name, maxCount}]) | req.files — object keyed by field name | Different files from different fields (avatar + gallery) |
| upload.none() | Neither — text fields only | Multipart form with no file fields |
Validation: fileFilter and limits
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.
| MulterError.code | Meaning | Suggested status |
|---|---|---|
| LIMIT_FILE_SIZE | File exceeds the fileSize limit | 413 |
| LIMIT_FILE_COUNT | More files than the files limit | 400 |
| LIMIT_UNEXPECTED_FILE | Field name not in the accepted list | 400 |
| LIMIT_FIELD_COUNT | Too many non-file fields | 400 |
| LIMIT_FIELD_VALUE_SIZE | Non-file field exceeds fieldSize | 413 |
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
🔒 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
| API | Notes |
|---|---|
| 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.buffer | Raw bytes (memoryStorage only). Use to stream to S3 or process in memory. |
| req.file.path | Full path to the saved file (diskStorage only). |
| req.file.mimetype | Client-supplied MIME type. Validate but do not trust alone. |
| req.file.size | Bytes. Available after the file is received. |
| err instanceof multer.MulterError | True for limit violations. Check err.code for the specific limit. |
| limits.fileSize | Bytes. 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.
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).
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.