Building REST APIs with Node.js and Express That Don't Fall Apart at Route 40
TIL that an Express API without a consistent response/error shape is fine at 10 routes and a real liability at 40 — the fix was boring middleware, not a framework change.
The Problem #
Early routes on the Archery Garage backend each handled their own error responses inline — some returned { error: "message" }, others returned a raw string, one returned a 500 with no body at all. The client had no reliable way to read an error without special-casing individual endpoints.
Context #
The API grew organically: a route was added whenever the app needed one, with no shared conventions written down anywhere, because early on there were only a handful of endpoints and consistency didn't seem to matter yet.
What I Tried #
Went through and manually "fixed" each route's error response to match a format I'd decided on, one at a time, as I noticed the drift.
What Went Wrong #
New routes kept getting added the old way, because the "format" only existed as a convention in my head, not as anything enforced by the code. The manual fixes just meant the drift kept happening at a slower rate.
The Solution #
Moved response and error shaping into middleware instead of leaving it up to each handler. Route handlers just throw or return data; a central error middleware and a response wrapper handle formatting once.
// A route only does its own job
app.get("/orders/:id", asyncHandler(async (req, res) => {
const order = await getOrder(req.params.id);
if (!order) throw new ApiError(404, "Order not found");
res.json({ data: order });
}));
// One place formats every error the same way
app.use((err, req, res, next) => {
const status = err.status ?? 500;
res.status(status).json({ error: { message: err.message, status } });
});Why It Works #
A new route can't drift from the convention because the convention isn't optional per-route code — it's the only path an error or response can take out of the app. Consistency becomes a property of the architecture instead of something I have to remember.
Lessons Learned #
"I'll just be consistent" doesn't survive contact with a growing route count. If a convention matters, it has to be structurally enforced, not remembered.
What I Would Do Differently #
I'd set up the error middleware and an asyncHandler wrapper before writing the second route, not after noticing the third inconsistent error shape.
Related Concepts #
Centralized error handling middleware, consistent API response envelopes, asyncHandler patterns for async route handlers.
Related content
Serving Premium Downloads Without Exposing the File's Real Storage URL
TIL how to replace a guessable direct-download link with a permission-checked, time-limited presigned URL, so the object storage path a purchased asset actually lives at is never sent to the client.
Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.
Debugging Problems That Only Appear in Production
TIL that a bug I couldn't reproduce locally turned out to depend on a real difference between environments — request concurrency — that my local setup structurally couldn't produce, no matter how hard I tried to repro it.