Node.js Production Bugs I Keep Finding (And How to Catch Them Early)

Today

Most Node.js backends do not fail in spectacular ways on day one. They fail quietly after traffic arrives: sockets that never die, a single bad message that takes down a process, a deploy that leaves connections hanging, or a secret that was never meant to live in git.

These are the patterns I keep finding when I read other people's servers — and the same checks that power the free backend audit on this site. None of them require a fancy framework. They show up in Express APIs, raw ws servers, and Next.js route handlers alike.

1. WebSocket connections with no heartbeat

A TCP connection can die without a clean close: laptops sleep, NATs time out, mobile networks vanish. If your server never pings and never removes dead sockets, the connection table grows until memory or file descriptors give out.

With the raw ws library you need an application-level heartbeat. Socket.IO ships one; plain WebSockets do not.

const interval = setInterval(() => {
  for (const client of wss.clients) {
    if (client.isAlive === false) {
      client.terminate();
      continue;
    }
    client.isAlive = false;
    client.ping();
  }
}, 30_000);
 
wss.on("connection", (socket) => {
  socket.isAlive = true;
  socket.on("pong", () => {
    socket.isAlive = true;
  });
});
 
wss.on("close", () => clearInterval(interval));

If you are building real-time features from scratch, the WebSocket chat walkthrough covers heartbeat, auth, and shutdown in one codebase.

2. JSON.parse on socket or request bodies without a try/catch

One malformed frame should not crash the process. It will, if you parse untrusted input at the top level of a message handler:

socket.on("message", (raw) => {
  const data = JSON.parse(String(raw)); // throws → can take down the server
  handle(data);
});

Wrap the parse. Reject or ignore bad messages. The same rule applies to any path where clients control the bytes — not only WebSockets.

socket.on("message", (raw) => {
  let data;
  try {
    data = JSON.parse(String(raw));
  } catch {
    socket.close(1003, "invalid json");
    return;
  }
  handle(data);
});

When you are inspecting a failing payload offline, keep it local: the JSON validator and formatter run in the browser so tokens never leave your machine.

3. No graceful shutdown on SIGTERM

Containers and hosts send SIGTERM before they kill the process. If you ignore it, in-flight requests get cut off, WebSocket clients reconnect in a storm, and database pools are abandoned mid-query.

Listen once, stop accepting work, close the server, then exit:

async function shutdown(signal) {
  console.info(`received ${signal}, shutting down`);
  clearInterval(heartbeat);
  await new Promise((resolve, reject) => {
    server.close((err) => (err ? reject(err) : resolve()));
  });
  await pool.end();
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Give yourself a timeout so a stuck close cannot hang the deploy forever.

4. Secrets and signing keys as string literals

"supersecret" in source is not a joke example — it ships. Database URLs, JWT secrets, and API keys belonging in environment variables (or a secret manager), not in the repo and not in client bundles.

// bad
const token = jwt.sign(payload, "supersecret");
 
// better
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET is required");
const token = jwt.sign(payload, secret, { expiresIn: "1h" });

Also set an expiry. Tokens that never expire are a gift to anyone who steals one.

5. CORS and WebSocket origins left wide open

Access-Control-Allow-Origin: * on a credentialed API, or accepting every WebSocket Origin, turns a browser into an easy confused-deputy. Lock origins to the sites you actually serve. Fail closed in production when the allowlist is missing.

6. Async Express handlers that swallow rejections

An async route that throws (or rejects) without a path to error middleware becomes an unhandled rejection. In older Node versions that could take the process down; in newer ones you still lose the request and the log line you needed.

Use a wrapper or a framework helper so rejections reach your error handler:

const asyncRoute = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};
 
app.get("/items/:id", asyncRoute(async (req, res) => {
  const item = await db.item(req.params.id);
  if (!item) return res.status(404).end();
  res.json(item);
}));
 
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "internal" });
});

How to catch these before production

You do not need a perfect checklist on day one. You need the boring ones that hurt under load:

  1. Heartbeats and idle timeouts on long-lived connections.
  2. Parse boundaries around every untrusted string.
  3. SIGTERM / SIGINT shutdown that closes servers and pools.
  4. Secrets only from the environment, with required checks at boot.
  5. Explicit origin allowlists.
  6. Async errors that reach a real error handler.

Paste a single server file into the free backend audit for a quick pattern pass — it runs in your browser and never uploads the code. It will not replace a human reading the rest of the repo, but it surfaces the failure modes above in seconds.

If the findings are real and you want them fixed at a fixed price, that is exactly what bug fixing services are for.

FAQ

Are these bugs specific to Express?

No. Heartbeats and JSON parsing show up on any WebSocket server. Graceful shutdown and secrets matter for any long-running Node process. Express and Next.js just make a few of the HTTP variants easier to spot.

Will a linter catch all of this?

Sometimes. ESLint can flag JSON.parse without try/catch if you wire a rule for it. It will not understand your WebSocket heartbeat design or whether SIGTERM closes the right server. Pattern checks plus a short human review still win.

Should I always use Socket.IO instead of ws?

Use Socket.IO when you want batteries-included reconnect and heartbeats. Use ws when you want a thin protocol and are willing to own ping/pong, backpressure, and auth yourself. Either can be production-safe; neither is automatic.

Related Reads