I've worked on enough Node.js backends at this point to start seeing the same problems come up over and over. Some of them are beginner mistakes. Some of them show up in codebases written by people who really should know better.
Here are the ones I see most often.
Not handling async errors properly
This one is everywhere. Someone writes an async function, forgets to wrap it in a try/catch, and now unhandled promise rejections are silently swallowing errors in production.
// this will swallow errors
app.get("/users", async (req, res) => {
const users = await getUsers();
res.json(users);
});
// this won't
app.get("/users", async (req, res, next) => {
try {
const users = await getUsers();
res.json(users);
} catch (err) {
next(err);
}
});
Better yet, use a wrapper utility or a library like express-async-errors so you don't have to repeat that pattern everywhere.
Putting everything in one file
I've opened Node.js projects where the entire app, routes, business logic, database calls, and middleware, lives in a single index.js. It works until it doesn't. Once the project grows it becomes impossible to navigate and a nightmare to test.
A simple structure goes a long way:
src/
routes/
controllers/
services/
middleware/
utils/
Controllers handle the request and response. Services handle the business logic. Keep them separate and your codebase stays readable as it scales.
Blocking the event loop
Node.js is single threaded. If you run a heavy synchronous operation on the main thread, everything else waits. I've seen people do things like parse a large JSON file synchronously or run a CPU intensive calculation right in a route handler.
If you have heavy work to do, offload it. Use worker threads, a job queue, or move it out of the request cycle entirely.
No validation on incoming data
Trusting whatever comes in from the client is asking for trouble. I've seen backends that pass req.body directly into a database query with zero validation. That's how you get corrupted data and security issues.
Use a validation library like zod or joi. Define your schema, validate early, and reject bad input before it gets anywhere near your business logic.
const schema = z.object({
email: z.string().email(),
amount: z.number().positive(),
});
const result = schema.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error);
No centralized error handling
Error handling scattered across every route is messy and inconsistent. Users end up getting different error shapes depending on which route blew up. Set up a single error handling middleware and funnel everything through it.
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({
message: err.message || "Something went wrong",
});
});
One place to update, consistent responses everywhere.
Ignoring environment configuration
Hardcoded secrets, API keys sitting in the codebase, no .env setup. Still happens more than it should. Use dotenv, never commit secrets, and validate your environment variables at startup so the app fails loudly if something is missing rather than breaking mysteriously at runtime.
No logging worth reading
console.log("error:", err) is not a logging strategy. In production you need structured logs you can actually search and filter. Something like winston or pino gives you log levels, JSON output, and the ability to plug into whatever monitoring setup you're using.
If you can't tell what your backend was doing when something went wrong, that's a problem you'll feel at the worst possible time.
None of these are hard to fix. Most of them just require slowing down and being intentional about how the project is set up from the start. A backend that's easy to debug and maintain is worth more than one that was fast to throw together.