Home / Articles / Common Node.js and Database Misconceptions That Cause Production Bugs

This article is published in English.

Common Node.js and Database Misconceptions That Cause Production Bugs

Learn why async/await, connection pooling, and ORMs don't automatically prevent race conditions, connection exhaustion, or SQL injection in Node.js apps.

1442 words

Node.js and databases have a rocky relationship built on a common trap: getting something functional up and running is so easy that developers form assumptions from that first success and never question them again. Those assumptions hold up fine until real traffic, concurrent users, or larger data volumes reveal the gap between what seemed true and what was actually happening. Below are five of the most damaging misconceptions, along with an explanation of what's really going on in each case.

The assumption: using async/await automatically protects database operations from race conditions.

What's really true: async/await simply lets asynchronous code look sequential when you read it. It provides no atomicity guarantee for database operations, and two separate requests can still interleave in a way that produces an incorrect outcome.

// looks sequential, isn't safe under concurrency
async function reserveSeat(eventId, seatNumber) {
  const seat = await db.query(
    "SELECT status FROM seats WHERE event_id = $1 AND number = $2",
    [eventId, seatNumber]
  );
  if (seat.status === "available") {
    await db.query(
      "UPDATE seats SET status = 'reserved' WHERE event_id = $1 AND number = $2",
      [eventId, seatNumber]
    );
  }
}

Two separate requests can each execute the SELECT, each see the seat marked as available, and each proceed to book that same seat. That happens because await only pauses execution for the request that issued it — it does nothing to prevent a second, unrelated request from slipping in between a read and its corresponding write. The real solution isn't something a JavaScript-level fix can address; it has to happen at the database layer, using either an atomic conditional update or a transaction with proper row locking.

async function reserveSeat(eventId, seatNumber) {
  const result = await db.query(
    `UPDATE seats SET status = 'reserved'
     WHERE event_id = $1 AND number = $2 AND status = 'available'
     RETURNING *`,
    [eventId, seatNumber]
  );
  return result.rowCount > 0; // false means someone beat you to it
}

async/await is nothing more than syntactic sugar for working with promises. It was never designed to guarantee concurrency safety, and treating it as if it does is exactly how double-booking bugs happen.

The assumption: because Node runs on a single thread, connection pooling isn't as critical as it would be in a multi-threaded language.

What's really true: Node's single-threaded nature applies to how JavaScript executes, not to how the database handles I/O. A single Node process can easily have hundreds of database queries in flight simultaneously, and each one is a genuine network round-trip to a real database, which must open, keep alive, and eventually close an actual connection for every single one.

// a new connection per query, under real traffic, this collapses fast
async function getUser(id) {
  const conn = await mysql.createConnection(config);
  const [rows] = await conn.query("SELECT * FROM users WHERE id = ?", [id]);
  await conn.end();
  return rows[0];
}

Every call to something like createConnection involves a TCP handshake and an authentication step, and virtually every database enforces a hard ceiling on how many connections it will accept at once. Connection pooling spreads that overhead out by keeping a batch of connections open in advance and handing them out as they're needed:

const pool = mysql.createPool({ ...config, connectionLimit: 10 });
async function getUser(id) {
  const [rows] = await pool.query("SELECT * FROM users WHERE id = ?", [id]);
  return rows[0];
}

Node's concurrency model is precisely the reason pooling matters, not a reason to skip it. A single Node process really can and does try to run dozens of queries in parallel at any given moment.

The assumption: an ORM eliminates the need to think about SQL injection at all.

What's really true: That protection holds only as long as the code stays within the ORM's own query-building API. It disappears the instant a raw query gets written or a WHERE clause gets assembled through string concatenation — something that happens more frequently than expected, especially once a query becomes complex enough that the ORM's abstractions start feeling restrictive.

// still vulnerable, ORM or not
const results = await sequelize.query(
  `SELECT * FROM users WHERE email = '${userInput}'`
);

An ORM's safety comes specifically from parameterized queries running underneath it, not from some universal shield that follows the code wherever it goes. As soon as SQL is being constructed as a plain string, that protection has been left behind, regardless of whether an ORM sits above it:

const results = await sequelize.query(
  "SELECT * FROM users WHERE email = :email",
  { replacements: { email: userInput }, type: QueryTypes.SELECT }
);

The rule that genuinely holds up: user-supplied input must never be concatenated directly into a query string, no matter what abstraction layer sits between the code and raw SQL.

The assumption: an unhandled error from a database call will automatically land in Express's error-handling middleware.

What's really true: Express's built-in error handling captures synchronous exceptions thrown inside route handlers, along with errors passed explicitly via next(err). It does not automatically capture a rejected promise coming out of an async route handler, unless the setup runs an Express version that natively supports that behavior or has been configured to handle it manually.

// on many Express setups, a rejected promise here never reaches your error handler
app.get("/users/:id", async (req, res) => {
  const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
  res.json(user);
});

If that query's promise rejects and nothing is there to catch it, the result is an unhandled promise rejection — which, in current Node versions, can crash the entire process. That takes down every other request being processed at that moment, not merely the one that triggered the failure.

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
    res.json(user);
  } catch (err) {
    next(err); // now Express's error handler actually sees it
  }
});

Wrapping each async route by hand becomes tedious quickly, which is exactly why it's worth setting up, early in a project, either a lightweight middleware wrapper or an Express version with native async error support, rather than assuming errors will somehow route themselves correctly on their own.

The assumption: a query that runs fast in local development will perform just as well in production.

What's really true: Local development databases tend to be small, casually indexed at best, and running on hardware nobody is putting under real strain. A query scanning ten thousand rows on a laptop and the same query scanning ten million rows in production are effectively different queries in every practical sense, even though the SQL text is identical.

// fine with 500 test rows, a real problem with 5 million production rows
const orders = await db.query(
  "SELECT * FROM orders WHERE customer_email = $1 ORDER BY created_at DESC"
);

Without an index on customer_email, this query triggers a full table scan, and the gap between "instantaneous" and "takes several seconds" is purely a function of table size — something local development environments almost never reflect honestly. The habit that actually provides protection isn't writing the code differently; it's testing against data volumes that resemble production, or at minimum running EXPLAIN against a production-sized table before assuming that something working locally says anything meaningful about how it will behave under real load.

What Ties All Five Together

Every one of these misconceptions traces back to the same root cause: something appeared to work, and that apparent success got promoted to a rule instead of being recognized as a single outcome that happened not to fail. Node and a database are two distinct systems communicating over a network, each with its own guarantees and its own ways of breaking, and JavaScript's readable syntax doesn't dissolve that boundary just because it makes the code easier to follow. The actual fixes involved are rarely complicated. The real skill lies in recognizing which assumption deserves to be questioned in the first place.