DEV Community

Cover image for I Debugged a Silent Upload Failure for 6 Hours. Here's the Root Cause.
Simon Briggs
Simon Briggs

Posted on

I Debugged a Silent Upload Failure for 6 Hours. Here's the Root Cause.

It was 11 PM on a Tuesday. QA had flagged one line in the bug tracker: "File upload doesn't work sometimes." No stack trace. No error in the logs. No 4xx or 5xx response. Just a 200 OK and a file that never showed up in storage.

If you've done backend work for more than a year, you already know the specific dread this causes. A crash is a gift. A crash tells you exactly where to look. A silent failure tells you nothing, and it hands you six hours of your evening to figure out why "nothing went wrong" and "nothing worked" are somehow both true at the same time.

Here's what actually happened, and why the fix took so long to find.

The Setup

The stack was straightforward: an Express API, multer handling multipart uploads, and a React frontend sending FormData through fetch. Nothing exotic. The kind of setup that works in a thousand tutorials without a single caveat mentioned.

The endpoint looked something like this:

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// ... other routes ...

app.post('/api/upload', upload.single('file'), (req, res) => {
 if (!req.file) {
   return res.status(400).json({ error: 'No file received' });
 }
 saveToStorage(req.file);
 res.status(200).json({ success: true });
});
Enter fullscreen mode Exit fullscreen mode

Simple enough. It worked in local dev, every single time. It worked in staging, most of the time. In production, it failed intermittently, for some users, on some files, with zero pattern anyone could pin down.

The First Four Hours: Chasing Ghosts

The first instinct was to blame the frontend. Maybe the FormData object wasn't being built correctly. Maybe a race condition in the file input's onChange handler was grabbing a stale reference. I added logging before the fetch call, confirmed the file was attached, confirmed the request was firing, and confirmed the payload size looked right in the Network tab.

Frontend was clean. Move to the backend.

Next theory: file size limits. Multer has a limits config, and silently rejecting oversized files felt like a plausible culprit. I bumped every limit I could find, redeployed, and the bug still showed up on files well under any threshold.

Then I suspected the reverse proxy. Nginx has its own client_max_body_size, and if that's set too low, it can truncate or reject a request in ways that don't always surface clearly to the app layer. I checked the config. It was fine, generous even.

Four hours in, I had ruled out the frontend, the file size limits, and the proxy. I had also, somewhere around hour three, started questioning my career choices. This is the part of debugging nobody puts in the tutorial: the long stretch where every reasonable hypothesis is wrong, and you start suspecting the compiler, the runtime, and possibly the concept of computers in general.

The Breakthrough

The turning point came from something almost embarrassingly small. I added a raw console.log(req.headers['content-type']) at the very top of the middleware stack, before multer ever touched the request. Most requests logged the expected multipart/form-data; boundary=.... But on the failing ones, by the time the log fired inside the upload handler, req.body was already a fully parsed, empty object.

That was the clue. req.body should not exist yet at that point in a multipart request. Multer builds it. Something upstream was already consuming the request stream.

The culprit was express.json().

Body-parsing middleware in Express reads the incoming request stream to parse it. express.json() is supposed to skip requests that aren't JSON, and it usually does, based on the Content-Type header. But under specific conditions, involving a proxy or client that slightly reordered or duplicated headers, the check misfired. express.json() began consuming the stream on a request it should have ignored. By the time the request reached multer, the stream had already been partially drained. Multer received a request it could not fully read, found no valid file part, and quietly moved on. No file, no error, no file attribute. Just an empty req.file, an unhelpful if check that didn't catch the actual cause, and a 200 response, because nothing had technically thrown.

Two middleware functions were racing to read the same stream, and the loser lost silently.

The Fix

The fix was almost anticlimactic after six hours of searching:

// Only parse JSON/urlencoded bodies for routes that need them
app.use('/api/json-routes', express.json());
app.use('/api/json-routes', express.urlencoded({ extended: true }));

// Upload route never touches body-parsing middleware
app.post('/api/upload', upload.single('file'), (req, res) => {
 if (!req.file) {
   return res.status(400).json({ error: 'No file received' });
 }
 saveToStorage(req.file);
 res.status(200).json({ success: true });
});
Enter fullscreen mode Exit fullscreen mode

Scoping body-parsers to the routes that actually need them, instead of applying them globally with app.use(), removed the race entirely. multer now gets the raw stream first and every time, because nothing else is allowed to touch it beforehand.

The Actual Lesson

The root cause wasn't a bug in Express, or in multer, or in the frontend. It was an architectural assumption: that global middleware is always safe because it's "just parsing." Middleware order in Express is a shared resource. Two pieces of middleware that both want to read the request stream will eventually collide, and when they do, the failure mode is often silence rather than a crash, because neither one considers the other's job a failure condition.

If there's a takeaway worth pinning to your desk, it's this: audit what your global middleware actually touches, not just what you assume it touches. express.json() feels inert on a route with no JSON body. It isn't. It reads first and asks questions later.

On a side note, this whole ordeal happened while I was also rebuilding an internal tool for handling document conversions on the side, which made the irony sharper: half my week was spent making sure files got where they needed to go reliably, and the other half was spent using PDF Conveter to quickly merge and compress PDFs for a client deliverable, because sometimes you just need a browser-based tool that doesn't ask you to think about middleware order at all.

If you've hit your own "silent failure" that turned out to be a middleware ordering issue, or something equally invisible, I'd genuinely like to hear it in the comments. These bugs rarely make it into blog posts because they're not glamorous, but they're the ones that teach the most about how our frameworks actually work under the hood.

Top comments (0)