Express.js REST API - Silent Hang from Missing next()
Missing next() in Express.js validation hangs POST requests 30s leading to 504.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- Express.js maps HTTP verbs + URL paths to route handlers
- Middleware functions process requests in a pipeline before your route
- app.use() order determines execution order – register json parser first, error handler last
- Router modules let you group related routes under a prefix, keeping code modular
- Production insight: forgetting next() in middleware hangs the request silently – every code path must call next() or send a response
Imagine a restaurant. You (the customer) sit at a table and place an order. The waiter takes that order to the kitchen, the kitchen prepares the food, and the waiter brings it back. A REST API is exactly that waiter — it sits between your app (the customer) and your database or business logic (the kitchen). Express.js is the training manual that tells the waiter exactly how to behave: which orders to accept, in what format, and what to do when something goes wrong. You're not building the restaurant from scratch — you're hiring a very well-trained waiter.
Every app you use daily — Spotify, GitHub, your bank's mobile app — talks to a server through an API. When Spotify's mobile app asks 'give me this user's playlists', it sends an HTTP request to a REST API, which fetches the data and sends it back as JSON. Express.js is the most popular framework for building those APIs in Node.js, and for good reason: it's minimal, fast, and gives you exactly as much structure as you need without forcing a rigid pattern on you.
Before Express existed, building an HTTP server in raw Node.js meant writing dozens of lines of boilerplate just to read a URL or parse a request body. Express solves that. It wraps Node's built-in http module with a clean, chainable API for defining routes, plugging in middleware, and sending structured responses. The result is that you can go from zero to a working API endpoint in about ten lines of code — but knowing why each of those lines exists is what separates a junior who copies tutorials from an engineer who can debug, scale, and maintain a real service.
By the end of this article, you'll have a fully working RESTful API for a book library — complete with all four CRUD operations, proper HTTP status codes, input validation middleware, and a global error handler. More importantly, you'll understand the mental model behind each decision so you can apply these patterns to any domain, not just this example.
What Express.js REST API Actually Is
Express.js is a minimal, unopinionated web framework for Node.js that maps HTTP methods and routes to handler functions. Its core mechanic is a middleware pipeline: each request passes through a stack of functions in order, and each function can end the request-response cycle or pass control to the next function by calling next(). This pipeline model gives you fine-grained control over request processing, authentication, logging, and error handling.
In practice, Express treats everything as middleware — even route handlers. A route handler is just middleware that doesn't call next(). The framework provides a simple API: app.get(), app.post(), app.use(), and so on. Each middleware receives (req, res, next). If you forget next(), the request hangs until timeout. This is not a bug; it's the contract. The pipeline stops at the first handler that does not call next().
Use Express when you need a lightweight, flexible HTTP server for APIs, microservices, or server-rendered apps. It shines in projects where you want to compose behavior via middleware rather than inherit from a framework. Its simplicity means you must enforce structure yourself — no built-in validation, no ORM, no opinion on project layout. That's the trade-off for speed and control.
next() in middleware is the #1 cause of unresponsive Express routes in production — the request never completes, no error is thrown, and the client times out.next() on success. Every valid request hung for 30 seconds until the load balancer timeout killed it, causing 503s during peak hours.next() unconditionally, or the pipeline deadlocks.next().next() causes a silent hang, not an error — the request will timeout.next() or sends a response.Middleware — The Assembly Line That Runs Before Your Route Handler
Middleware is Express's killer feature, and it's the concept most beginners underestimate. A middleware function is just a function with three arguments: req, res, and next. When Express receives a request, it runs it through a pipeline of middleware functions in the order they were registered. Each function can read the request, modify it, respond to it, or pass control to the next function by calling .next()
Think of it like airport security. Before you reach your gate (the route handler), your bag goes through X-ray (logging middleware), you show your passport (authentication middleware), and you get patted down (validation middleware). If any step fails, the process stops and you don't board the plane.
This pattern is powerful because it separates concerns cleanly. Your route handler shouldn't care about logging or authentication — it should only care about its specific business logic. Middleware handles the cross-cutting concerns that apply across many routes.
There are three types you'll use constantly: application-level middleware (registered with ), router-level middleware (scoped to a specific Router instance), and error-handling middleware (four-argument functions: app.use()err, req, res, next). The order of registration is everything — middleware registered later in the file won't intercept requests that were already responded to by earlier handlers.
next() AND doesn't send a response, the HTTP request will sit there until the client times out. Express won't throw an error — it just silently hangs. Always make sure every code path in a middleware either calls next(), next(err), or sends a response. A linter rule like eslint-plugin-node can catch this pattern.next() is the #1 cause of silent hangs in Express APIs. It's not a crash — it's a timeout, and it looks like a network issue.next(), next(err), or a response method (res.send/res.json/res.end).app.use() order = execution ordernext() or send a response — no exceptionsExpress Router — Organising Routes Like a Real-World Codebase
When your API has more than one resource — say books, authors, and orders — putting every route in a single server.js file becomes unmanageable fast. Express Router solves this by letting you create mini-applications that handle a subset of routes, then mount them onto your main app at a specific path prefix.
Think of it like a post office. The main post office (your app) receives all mail. It then hands packages destined for '42nd Street' to the 42nd Street department (your /api/books Router), packages for '5th Avenue' to a different department, and so on. Each department handles its own internal sorting without the main post office needing to know the details.
This isn't just an organisational preference — it's the pattern that makes your codebase testable and scalable. Each Router module can be imported, tested independently, and even reused. When a new developer joins your team, they can look at routes/books.js to understand everything about the books resource without reading the entire codebase.
The middleware cascade works here too: any middleware registered on the Router only runs for routes within that Router. This is how you can protect your entire /api/admin route group with auth middleware without touching any other route.
router.get('/:id', ...) before router.get('/featured', ...), Express will match /api/books/featured as a request for book with id 'featured' — not your featured route. Always register specific, literal routes BEFORE parameterised ones. This is a real interview question too — interviewers love to ask why /users/me isn't matching as expected.Error Handling Patterns — From Validation to Global Catchers
Express gives you a structured way to handle errors that keeps your route handlers clean and your error responses consistent. The pattern is simple: when something goes wrong in a middleware or route handler, you call next(err) with an Error object. Express then skips all remaining non-error middleware and goes straight to the error-handling middleware — the one with four parameters (err, req, res, next).
This is important because it means you don't need try/catch blocks scattered across every route. Instead, you have a single place where all errors are caught, logged, and formatted into a consistent JSON response. The error handler is also where you decide what to expose to the client — never leak stack traces in production.
There's a nuance with async handlers. An async function that throws will result in an unhandled promise rejection — Express won't catch it automatically unless you use a wrapper like express-async-errors or explicitly wrap each handler. In recent Node versions (16+), unhandled rejections cause the process to exit, which is even worse. The safest approach is to install express-async-errors at the top of your entry file — it patches Express to catch async errors for you.
err.stack in the response body. It exposes file paths, internal variable names, and possibly secrets. Always check NODE_ENV before including stack traces. A malicious actor can use stack traces to map your directory structure.Production Patterns — Config, Logging, and Graceful Shutdown
A REST API that works on your laptop is not a production-ready API. Production means handling environment-specific configuration, structured logging you can actually query, and a graceful shutdown that doesn't drop in-flight requests.
Environment configuration is trivial but often done wrong. Hardcoding things like database URLs or API keys in the codebase is a security incident waiting to happen. Use process.env with sensible defaults for development, and validate that required variables are present on startup.
Logging in production should be structured JSON, not freeform text. Tools like ELK, Datadog, or Grafana expect log lines as JSON objects so they can be filtered and aggregated. Use a library like pino or winston — console.log is fine for development but worthless at scale.
Graceful shutdown is the one most people forget. When you send SIGTERM to your Node process (e.g., during a deployment), any ongoing HTTP requests get aborted. You need to listen for the signal, stop accepting new connections, wait for pending requests to finish, and then close. Express doesn't do this by default — you need to wrap in the signal handler.server.close()
- Environment config = externalise everything that changes between environments.
- Structured logging = JSON output that tools can parse; console.log is for debugging only.
- Graceful shutdown = listen for SIGTERM, stop accepting, drain requests, then exit.
- Health checks = expose a /healthz endpoint that returns 200 so orchestrators know you're alive.
- Port configuration = read from environment with a sensible default, and validate on startup.
server.close() with a timeout.Response Streaming — The Difference Between 200ms and 20ms
You see Express calling res.json() and think that's the final word. It's not. When you're serving large datasets — say a CSV export or an AI-generated stream — waiting for the entire payload to assemble in memory before sending it turns your API into a memory-sucking bottleneck. The WHY: Node.js runs on a single thread. If that thread is blocking on a 50MB JSON stringify, every concurrent request queues up. The HOW: Pipe data through streams. Use res.write() for chunked transfer encoding. For JSON arrays, use a transform stream that writes each object as it's ready. This lets the client render progress bars, process partial results, or abort mid-stream. Production trap: forgetting to handle backpressure. If your client reads slower than you write, memory balloons. Check stream.readableHighWaterMark and implement .pipe() with proper error propagation. The result? Your API stays responsive under load, and your users aren't staring at a spinner waiting for the whole payload.
res.end() inside a stream error handler. It double-closes the connection. Always use res.destroy(error) to let the client know something broke.Every time your route handler calls new Pool() or creates a connection manually, you're building a rope for your own hanging. The WHY: Opening a database connection is expensive — TCP handshake, SSL negotiation, authentication. Do that per-request at 1000 req/s and your database will throttle you or your Node event loop will stall waiting on I/O. The HOW: Use a singleton connection pool initialized once at app startup. Libraries like pg or mysql2 export Pool instances you configure with min/max connections. Set the max to something sane — typically 10-20 per process — and let the pool queue requests. Monitor pool.waitingCount and pool.totalCount in your health endpoint. Production trap: forgetting to release connections back to the pool. If you catch an error and return early without calling client.release(), you leak a connection. After 20 leaked connections, your pool is empty and your API hangs. Fix: use try/finally blocks or a dedicated context manager.
The Silent Hang: Forgetting next() in a Validation Middleware
next(). The route handler never executed, and no response was sent. The middleware simply exited, leaving the request hanging.next() call at the end of the middleware function, and ensure every code path either sends a response or calls next(). Also added a linter rule (eslint-plugin-node) to catch missing next() calls.- Never assume every code path in a middleware calls
next()— review all branches explicitly. - Use a linter to enforce that middleware functions always either call
next()or send a response. - Add a request timeout middleware (e.g. express-timeout) as a safety net so hung requests don't wait forever.
next() or res.end(). Use console.log('reached here') at the end of each middleware to see if execution reaches it.express.json()) is called BEFORE any route handlers. If it's after a route, that route won't have parsed body.console.log(app._router.stack.map(layer => layer.name));curl -v -X POST http://localhost:3000/api/books -H 'Content-Type: application/json' -d '{}'express.json()) before all route definitions in your app file.Key takeaways
express.json()) must come before any route that reads req.body, and globalErrorHandler must always be the last app.use() call in your file.server.close()).Common mistakes to avoid
3 patternsNot calling next(err) in async route handlers
Registering the global error handler before your routes
app.use() calls.Using 200 for all responses regardless of outcome
Interview Questions on This Topic
What is the difference between 401 Unauthorized and 403 Forbidden, and when would your Express API return each one?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Node.js. Mark it forged?
7 min read · try the examples if you haven't