Back to Blog
Node.jsJuly 2, 20264 min read

Express.js Async Timeout: Prevent Middleware Hangs and Socket Stalls

Master express.js async timeout patterns to prevent middleware hangs. Learn how to stop socket stalls and keep your event loop running at peak performance.

Node.jsExpress.jsMiddlewarePerformanceAsyncTimeoutBackend

During a recent production incident, one of our microservices hit a wall—literally. A downstream API call stopped responding, but because our middleware didn't have an explicit timeout, the Node.js process just sat there, waiting indefinitely for a socket that would never close. This eventually exhausted our connection pool, leading to a cascade of 504s across the entire service.

If you aren't managing your request lifecycle with a strict express.js async timeout, you're essentially leaving your application’s stability to chance. Default Node.js timeouts are often too generous, or sometimes nonexistent, depending on your library choice.

Why Default Timeouts Aren't Enough

We initially tried relying on the standard server.timeout property. It felt like the "official" way to handle things. However, we quickly realized that server.timeout kills the entire socket, which is a blunt instrument. If you have a long-running file upload and a quick JSON API sharing the same server, a global timeout will prematurely terminate legitimate, heavy requests.

To prevent event loop blocking, you need granular control. We needed a way to flag a specific route as "timed out" without nuking the entire connection lifecycle for other users.

Implementing a Custom Timeout Middleware

The most effective approach I’ve found is wrapping the request in a race condition. You define a promise that rejects after X milliseconds and race it against your actual business logic.

Here is a simplified version of the middleware we use in production:

JAVASCRIPT
const timeoutMiddleware = (ms) => (req, res, next) => {
  const timeoutId = setTimeout(() => {
    if (!res.headersSent) {
      res.status(508).send({ error: CE9178">'Request Timeout' });
    }
  }, ms);

  res.on(CE9178">'finish', () => clearTimeout(timeoutId));
  next();
};

This is basic, but it works. When I first implemented this, I forgot to clear the timeout on res.on('finish'), which caused a memory leak as the process kept track of thousands of abandoned timeout timers. Don't make that mistake.

Handling Async/Await Complexity

If you are using custom asyncHandler wrappers, you can integrate the timeout directly into your flow. Instead of just attaching a timer to the request object, you can wrap your controller logic.

StrategyProsCons
server.timeoutZero code, globalBlunt, kills all connections
req.setTimeoutBuilt-in, per-requestHard to track in complex chains
Promise.raceGranular, preciseRequires careful error handling

When you implement an express.js async timeout using Promise.race, ensure your logic catches the rejection. If you don't, you'll end up with unhandled promise rejections that could crash your node process.

Preventing Socket Stalls

A common pitfall is assuming the request handler is the only place where things hang. We once had a scenario where our database client was holding a connection open while waiting for a handshake, long before it even reached our route handler.

To mitigate this, I recommend setting a socketTimeout on your DB clients (like pg or mongodb) in addition to your application-level timeouts. It’s part of a broader strategy for custom middleware development where you account for every layer of the I/O stack.

Practical Tips for Success

  1. Be aggressive with numbers: If your P99 is 200ms, set your timeout to 1500ms. Don't give it "plenty of room." If it hasn't finished by then, it’s likely hanging or dead.
  2. Log the timeouts: Use a custom logger to track how often these timeouts trigger. If you see a spike, you know exactly which downstream service is failing.
  3. Use the 508 status code: It’s the official HTTP status for "Loop Detected," but it’s often repurposed for "Resource Limit Exceeded." It’s cleaner than returning a 500 error, which might trigger false alerts in your monitoring tools.

What I’d Do Differently

Next time, I would move away from manual setTimeout implementations and leverage an existing library like connect-timeout or a dedicated circuit breaker pattern for inter-service communication. Writing your own middleware is great for learning, but in a distributed system, you want battle-tested code that handles edge cases like header flushes and socket resets better than a simple setTimeout block ever could.

We're still refining our monitoring to distinguish between a "slow" request and a "hanging" request, as the latter is a much clearer indicator of a dependency failure.

Similar Posts