~/webline_global $

// Everyday tech, explained simply.

Why Your Node.js Backend Freezes During File Uploads

· 6 min read
Why Your Node.js Backend Freezes During File Uploads

You’ve got a Node.js server humming along in production, handling API calls, serving your React frontend, maybe running a WebSocket feed for a real-time leaderboard. Everything’s smooth until a user uploads a 50 MB PDF, and suddenly—the whole backend freezes. No requests go through. Not even a simple health check. That’s not a traffic spike; that’s a single file upload grinding your single-threaded event loop to a halt.

The fix isn’t more RAM or a bigger cloud instance. It’s understanding exactly where Node.js blocks, and how to route around that bottleneck without rewriting your entire stack.

The Event Loop Trap You Didn’t Know You Set

Node.js runs on a single main thread, and the event loop is the engine that processes everything—HTTP requests, database queries, file I/O. When you upload a file, the incoming data arrives in chunks (TCP packets), and your server has to read each chunk, write it to disk, and wait for the OS to confirm the write is done. If you handle that synchronously or with a naive fs.writeFile inside the request handler, you’ve just parked the event loop.

Why multer Alone Isn’t Enough

Most developers reach for multer or busboy to parse multipart form data. These libraries are stream-based, so they don’t load the entire file into memory at once. That’s good. But the stream still runs on the main thread. If your upload handler does anything blocking—like parsing a CSV row by row, compressing an image with sharp, or even logging the file metadata synchronously—the event loop stalls until that operation completes. A 200 MB video upload can lock your server for seconds.

The Real Cost of Synchronous File Writes

Consider this common pattern: you receive a file, write it to disk, then return a URL. If you use fs.writeFileSync inside the route handler, the event loop cannot process any other pending callbacks until the write finishes. Even fs.writeFile (the async version) isn’t a silver bullet—it still uses the thread pool for the actual disk I/O, but the callback is queued on the event loop. If you have dozens of concurrent uploads, those callbacks pile up, starving timers, HTTP keep-alives, and WebSocket pings.

Three Patterns That Keep Your Server Responsive

You don’t need to abandon Node.js for file uploads. You need to shift the blocking work off the main thread and into dedicated worker processes or external services. Here are three battle-tested patterns I’ve used in production for handling high-throughput file ingestion on platforms that process thousands of uploads per day.

1. Offload to a Child Process Immediately

The moment a file starts streaming, pipe it directly to a child process that handles the write. Node’s child_process module gives you a separate process with its own memory and event loop. The parent server only sees the stream, not the write latency.

const { spawn } = require('child_process');
const { Writable } = require('stream');

function writeFileOffloaded(filePath) {
  const child = spawn('node', ['-e', `
    const fs = require('fs');
    const ws = fs.createWriteStream(process.argv[1]);
    process.stdin.pipe(ws).on('finish', () => process.exit(0));
  `, filePath]);
  return new Writable({
    write(chunk, encoding, callback) {
      child.stdin.write(chunk, callback);
    },
    final(callback) {
      child.stdin.end(callback);
    }
  });
}

This keeps the main process free to handle other requests while the child process absorbs the disk I/O penalty. You can even pool child processes to handle concurrent uploads without spawning a new one for every request.

2. Use Worker Threads for CPU-Bound Processing

If your upload pipeline includes image resizing, video transcoding, or data validation, those operations are CPU-bound. Worker threads are ideal here because they run in the same process but on a separate thread, sharing memory without blocking the event loop.

const { Worker } = require('worker_threads');

app.post('/upload', (req, res) => {
  const worker = new Worker('./imageProcessor.js');
  worker.postMessage({ filePath: req.file.path, options: { width: 800 } });
  worker.on('message', (result) => res.json({ url: result.url }));
  worker.on('error', (err) => res.status(500).send(err.message));
});

The worker thread processes the image and sends back the result. Meanwhile, the main thread is free to accept the next upload or serve a WebSocket heartbeat. The key is keeping worker creation cheap—reuse a thread pool instead of spawning a new worker per request.

3. Stream Directly to Object Storage

The most scalable pattern is to never write the file to your server’s disk at all. Stream the upload directly to S3, GCS, or Azure Blob Storage using a signed URL. Your server only handles the metadata and returns the final URL.

const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');

app.post('/upload', (req, res) => {
  const upload = new Upload({
    client: new S3Client({ region: 'us-east-1' }),
    params: {
      Bucket: 'my-bucket',
      Key: `${Date.now()}-${req.headers['x-file-name']}`,
      Body: req,
    },
  });
  upload.done().then((result) => res.json({ url: result.Location }));
});

This pattern eliminates disk I/O entirely from your Node.js process. The event loop only manages the stream piping, which is non-blocking and efficient. If your upload volume grows, you can scale the web tier independently from storage. Plus, you get CDN caching, versioning, and lifecycle management for free.

A Real-World Meltdown and How We Fixed It

I was consulting for a small iGaming studio that ran a Node.js backend for player account management. They had a feature where users uploaded ID documents for KYC verification. The server froze for 3–5 seconds every time someone uploaded a scanned passport. Players on the casino floor couldn’t load their balance, spin a slot, or even log out. The CEO thought it was a DDoS attack.

The culprit was a single sharp.resize() call inside the upload handler. The developer had added it to generate a thumbnail for admin review. sharp is CPU-intensive, and it was blocking the event loop for every upload. We moved the resize into a worker thread, and the freeze disappeared. The server handled 50 concurrent uploads without a hiccup.

Instrumenting the Blocking Path

Before you deploy any fix, you need to know exactly where your server stalls. Use the built-in perf_hooks module to trace event loop lag.

const { performance, PerformanceObserver } = require('perf_hooks');

const obs = new PerformanceObserver((items) => {
  const entry = items.getEntries()[0];
  if (entry.duration > 50) {
    console.warn(`Event loop lag: ${entry.duration}ms`);
  }
});
obs.observe({ entryTypes: ['function'] });

Log any handler that takes longer than 50ms. If you see file upload handlers in those logs, you’ve found the bottleneck. Pair this with a simple health endpoint that returns a timestamp—if the timestamp lags behind real time during uploads, your event loop is blocked.

When to Use a Reverse Proxy for Uploads

Sometimes the smartest move is to take Node.js out of the upload path entirely. Nginx can handle file uploads directly, writing them to a temporary directory, and then forward the metadata to your Node.js server via a simple POST. This is the architecture behind many high-traffic platforms.

location /upload {
  client_body_temp_path /tmp/uploads;
  client_max_body_size 500M;
  proxy_pass http://node_backend;
  proxy_set_header X-File-Path $request_body_file;
}

Nginx writes the file to disk without involving Node.js at all. Your backend receives only the file path, which it can then process asynchronously. This pattern also protects against slow clients—Nginx buffers the upload, so slow connections don’t tie up your Node.js worker processes.

The Forward-Looking Playbook

The next time your Node.js backend freezes during a file upload, don’t reach for more vertical scaling. Profile the event loop lag, move CPU-bound work to worker threads, and consider streaming directly to object storage. The single-threaded model is not a weakness—it’s a constraint that forces you to design for concurrency correctly. Once you internalize that file I/O and CPU processing do not belong on the main thread, you’ll build backends that stay responsive under any payload size. Your players won’t notice the uploads, and that’s exactly the point.