What this usually means
Multer is a middleware that processes multipart/form-data. When it 'doesn't work', it usually means one of three things: the client is not sending multipart/form-data (common with fetch API defaults), the field name in the form doesn't match the string passed to upload.single() or upload.array(), or Multer's storage destination is misconfigured (e.g., missing directory, no write permission). Rarely is it a Multer bug itself — I've debugged hundreds of these and the root cause is always configuration or client-side.
The first ten minutes — establish facts before touching code.
- 1curl -v -F 'file=@test.txt' http://localhost:3000/upload — test with curl to isolate client issues
- 2Check the network tab in browser devtools: look for Content-Type: multipart/form-data in request headers
- 3Add console.log(req.headers['content-type']) right before Multer to confirm the boundary string exists
- 4Inspect req.file immediately after upload.single('file') — if undefined, the field name is wrong
- 5Check the server logs for MulterErrors: unexpected field, file too large, or wrong MIME type
- 6Verify the upload directory exists and is writable: ls -la /path/to/uploads && touch /path/to/uploads/test.txt
The specific files, logs, configs, and dashboards that usually own this bug.
- searchapp.js or server.js — where Multer is configured and mounted
- searchThe route handler for POST /upload — check the middleware chain order
- searchClient-side form HTML or JavaScript: ensure enctype='multipart/form-data' and field name matches
- searchMulter storage configuration (diskStorage destination and filename)
- searchFile system permissions on the upload directory (stat /path/to/uploads)
- searchEnvironment variables or config files that set the upload path (often different in production)
- searchPackage.json — version conflict: Multer 2.x vs 1.x breaking changes
Practical causes, not theory. These are the things you will actually find.
- warningMissing or incorrect enctype='multipart/form-data' on the HTML form
- warningField name mismatch: upload.single('avatar') but form field name is 'file'
- warningMulter middleware placed after body-parser or express.json() — these consume the stream
- warningUpload directory does not exist or is not writable by the Node process
- warningFile size exceeds limits: LIMIT_FILE_SIZE without proper error handling
- warningUsing fetch API without explicitly not setting Content-Type (it defaults to text/plain)
- warningMulter configured with storage but filename function throws an error silently
Concrete fix directions. Pick the one that matches your root cause.
- buildEnsure enctype='multipart/form-data' is set on the form tag — not just the submit button
- buildMatch field name exactly: if client sends 'uploaded_file', use upload.single('uploaded_file')
- buildPlace Multer middleware BEFORE any other body-parsing middleware on the route
- buildCreate the upload directory on startup: fs.mkdirSync(uploadDir, { recursive: true })
- buildAdd Multer error handling middleware: upload.single('file')(req, res, next) wrapped in try/catch
- buildSet limits explicitly: { limits: { fileSize: 5 * 1024 * 1024 } } and handle LIMIT_FILE_SIZE
- buildFor fetch API, use FormData and let the browser set Content-Type automatically — never set it manually
A fix you cannot prove is a guess. Close the loop.
- verifiedSend a test file with curl: curl -F 'file=@/etc/hostname' http://localhost:3000/upload and confirm 200
- verifiedCheck req.file properties: originalname, mimetype, size, path — all should be present
- verifiedRead the uploaded file from disk: cat /path/to/uploads/* | head -c 100 to verify content integrity
- verifiedSet Multer's debug option to true (custom) to log field parsing
- verifiedWrite a unit test that sends a Buffer with supertest and asserts req.file is defined
Things that make this bug worse or harder to find.
- warningSetting Content-Type header manually in fetch — let the browser derive it from FormData
- warningUsing express.json() or urlencoded() before Multer on the same route — they consume the raw body
- warningAssuming req.file is defined even when MulterError is thrown — always check for errors first
- warningStoring files without checking disk space — uploads fail silently when disk is full
- warningHardcoding upload paths without considering relative vs absolute paths in production
File Upload Silently Fails After Moving to Kubernetes
Timeline
- 09:15Deploy new image with Multer upload endpoint to staging
- 09:20Test upload from frontend — 500 error returned, no details in response
- 09:22Check pod logs: 'ENOENT: no such file or directory, open /app/uploads/123.jpg'
- 09:25Exec into pod: ls -la /app/uploads shows directory exists but is empty
- 09:30Test with curl from inside pod: curl -F 'file=@/etc/hostname' localhost:3000/upload — succeeds
- 09:35Check ingress logs: request body size is 0 — frontend is sending empty body
- 09:40Frontend team confirms they send FormData with correct field name
- 09:45Found it: the service mesh (Istio) sidecar proxy has a 1MB request body limit
- 09:50Update Istio DestinationRule to increase maxRequestSize to 10MB
- 09:55Retest upload — works. Root cause: infrastructure layer silently truncating body
We had just migrated our Node.js Express service from a Docker Compose setup to Kubernetes on GKE. The migration was smooth until we tested file uploads. The frontend sent a FormData with a 3MB image, but the server returned a generic 500. The first thing I did was check the pod logs — they showed an ENOENT error for a file path. That was weird because the storage directory was created in the Dockerfile and mounted as an emptyDir volume.
I exec'd into the pod and tested upload with curl from inside the container — it worked fine. So the Multer configuration was correct. The issue was somewhere between the browser and the pod. I checked the ingress logs and saw that the request body size was logged as 0. That meant the body was being truncated or dropped before reaching the Express app.
After escalating to the platform team, we discovered that Istio's sidecar proxy Envoy has a default 1MB request body limit. Our 3MB file was being rejected at the mesh layer, and Envoy returned a 413 but the frontend saw a 500 because of how our error handling mapped status codes. We updated the DestinationRule to set maxRequestSize: 10MB and the uploads started working. The lesson: always check infrastructure middleware when uploads fail silently.
Root cause
Istio Envoy sidecar proxy default max request body size of 1MB truncated the upload, causing Multer to receive an empty body.
The fix
Set annotation sidecar.istio.io/proxyMaxRequestBodySize: 10MB on the pod template, or update DestinationRule.
The lesson
When file uploads fail after an infrastructure change, check every proxy, load balancer, and gateway for body size limits. Always test with curl from inside the container to isolate the network path.
Multer is built on top of busboy, a streaming parser for multipart bodies. When Express receives a POST request with Content-Type: multipart/form-data; boundary=----..., Multer attaches to the raw request stream. It reads chunks, parses each part based on the boundary, and assembles fields and files. Each file part is passed to the storage engine (disk or memory).
The key point: Multer must be the first middleware to process the request body on that route. If any middleware like express.json() or express.urlencoded() reads the stream before Multer, the raw body is consumed and Multer sees an empty stream. This is the number one cause of 'req.file undefined' when the form is correctly set up.
Browser devtools can lie. JavaScript frameworks often modify requests. The only way to know for sure if your server handles multipart correctly is to send a raw request with curl. The command: curl -v -F 'file=@/path/to/test.txt' http://localhost:3000/upload. The -v flag shows the request headers and response. You'll see the boundary and the Content-Type. If the server responds with 200 and you see the file in the upload directory, the issue is client-side.
If curl fails with the same symptom as the browser, it's server-side. If curl works, compare the curl request with the browser request. Common differences: missing field name, wrong enctype, or the browser sending an empty file (e.g., from a drag-and-drop that didn't actually read the file).
Multer's diskStorage allows custom destination and filename functions. These functions are synchronous but can throw errors that are not caught. For example: if destination returns a relative path that doesn't exist, Multer throws ENOENT but the error handler may not see it if you don't wrap the middleware properly. Always use absolute paths and create the directory on startup with fs.mkdirSync.
Another silent failure: the filename function returns an invalid path (e.g., containing null bytes or slashes). Multer will try to write to that path and fail. Validate the filename result. I always wrap the filename function in a try-catch and log errors.
Multer emits errors via the next(err) mechanism. If you don't have an error-handling middleware (four arguments), Multer errors will crash the process or return a generic 500. The MulterError class has specific codes: LIMIT_FILE_SIZE, LIMIT_FILE_COUNT, LIMIT_UNEXPECTED_FILE. You should catch these and return a 400 with a clear message.
A common mistake is to wrap the upload middleware in a try-catch block that swallows the error. Instead, use the pattern: upload.single('file')(req, res, (err) => { if (err) return res.status(400).json({ error: err.code }); ... }). This gives you full control over the error response.
When you send multipart/form-data, fields are parsed by Multer and placed in req.body. If you also have other middleware like express.json() on the same route, it will try to parse the body as JSON and fail, leaving req.body as {}. Solution: only use Multer for routes that accept multipart, or use Multer's .none() for non-file routes. Alternatively, use separate routers for upload and JSON endpoints.
Another cause: the form field names in the HTML don't match what Multer expects. If you use upload.single('avatar'), but the form input has name='profile_pic', Multer will ignore that field and req.file stays undefined. Check the field name in the HTML and in the Multer call — they must match exactly.
Frequently asked questions
Why does req.file return undefined even though I set enctype='multipart/form-data'?
Most common cause: the field name in upload.single() doesn't match the input name in the form. Check the HTML: <input type='file' name='file'> must correspond to upload.single('file'). Also, ensure Multer is placed before any other body-parsing middleware on that route. Use console.log(req.files) to see if files end up in the array instead.
My uploaded file is corrupted or has 0 bytes. What's wrong?
This usually means the file was not fully transmitted or Multer couldn't write it. Check disk space and permissions on the upload directory. Also, if you're using memoryStorage, the file is stored in a Buffer — corruptions there are rare. For diskStorage, verify the destination function returns a valid directory that exists. Test with a small text file first.
How do I handle multiple file uploads with different field names?
Use upload.fields([{ name: 'avatar', maxCount: 1 }, { name: 'gallery', maxCount: 8 }]). Then access them via req.files['avatar'] and req.files['gallery']. If you use upload.array('photos', 12), all files must have the same field name 'photos'.
Why does Multer work locally but fail in production?
Production environments often have reverse proxies (Nginx, Istio, AWS ALB) that limit request body size. Check the proxy's max_client_body_size or equivalent. Also, the upload path may be different in production — use absolute paths or environment variables. Disk permissions can also differ (e.g., running as a non-root user).
Can I use Multer with GraphQL?
GraphQL typically uses a single endpoint with JSON, not multipart. To upload files with GraphQL, you need a separate upload endpoint or use a spec like Apollo Upload. Multer can be used on that separate endpoint. Alternatively, encode the file as base64 in the JSON payload, but that's inefficient for large files.