Node.js and Databases

Node.js itself does not ship with a built-in client for talking to a full database server like MySQL, PostgreSQL, or MongoDB — instead, you install a small, well-maintained npm package (a “driver”) that speaks the database’s wire protocol over a TCP socket. Because Node’s I/O is asynchronous and event-driven, database drivers are built the same way: every query returns a promise (or takes a callback) instead of blocking the thread while it waits for a reply. Understanding how these drivers use connections, pools, and async I/O is the difference between an app that scales and one that grinds to a halt under load.

This lesson covers connecting to a relational database (MySQL, via mysql2) and a document database (MongoDB, via the official mongodb driver), since together they represent the two most common patterns you will meet in real projects. The same principles — pooling, parameterization, async error handling — apply to PostgreSQL (pg), SQLite, and every other driver in the Node ecosystem.

Overview: How Node.js Talks to Databases

A database driver is a plain npm package. It opens a TCP connection to the database server, sends commands in that database’s binary or text protocol, and parses the responses back into JavaScript values. None of this is part of Node’s core standard library — node:fs, node:http, and friends know nothing about SQL or MongoDB’s BSON format. That’s why the very first step in any database lesson is always npm install a driver.

What Node does provide is the async foundation the driver is built on. When you call connection.query(sql), the driver writes bytes to the socket and returns a promise immediately — it does not block the event loop waiting for the database to respond. Under the hood, socket I/O in Node is handled by libuv’s event-driven networking (not the thread pool used for file system calls), so the event loop stays free to handle other requests while the database does its work. When the response bytes arrive, libuv notifies the event loop, the driver parses the response, and your promise resolves — this is exactly the poll phase of the event loop doing its job.

A single connection can typically only run one query at a time (it’s a stateful TCP session), which is why real applications almost always use a connection pool instead: a fixed set of already-open connections that queries borrow from and return to when finished. This avoids the cost of the TCP handshake and database authentication on every single request.

A note on SQLite

Since Node 22.5, there is an experimental built-in module, node:sqlite, for embedded SQLite databases with zero dependencies. It’s worth knowing about for small tools and tests, but for talking to a real MySQL, PostgreSQL, or MongoDB server — the far more common production scenario — you still need an npm driver, which is the focus of this lesson.

Syntax

Every SQL driver in the Node ecosystem follows roughly the same shape: create a connection or pool, then call query (or execute) with a SQL string and an array of parameters.

const client = driver.createPool({ host, user, password, database });
const [rows] = await client.query("SELECT * FROM table WHERE id = ?", [id]);
await client.end();
Part Meaning
createConnection() Opens one TCP connection to the database. Good for scripts, wrong for servers handling concurrent requests.
createPool() Creates a reusable set of connections that queries check out and return automatically.
query(sql, params) Sends a SQL statement. The ? placeholders are filled in safely by the driver, never by string concatenation.
[rows] mysql2‘s promise API returns a two-element array: the result rows and field metadata. Destructure just the first element for normal queries.
end() / close() Releases the connection(s) back to the OS. Skipping this leaves a Node process that never exits on its own.

Examples

Example 1: A simple query with mysql2

Install the driver first:

npm install mysql2

mysql2/promise gives you a promise-based API so you can use async/await instead of callbacks.

import mysql from "mysql2/promise";

const connection = await mysql.createConnection({
  host: "127.0.0.1",
  user: "app_user",
  password: process.env.DB_PASSWORD,
  database: "shop",
});

const [rows] = await connection.query(
  "SELECT id, name, price FROM products LIMIT 5"
);
console.log(rows);

await connection.end();

Output:

[
  { id: 1, name: 'Wireless Mouse', price: 19.99 },
  { id: 2, name: 'USB-C Cable', price: 8.5 },
  { id: 3, name: 'Mechanical Keyboard', price: 79.0 }
]

This opens one connection, runs one query, prints the rows, then closes the connection. Notice the credentials come from process.env.DB_PASSWORD rather than being hardcoded — never commit real database passwords into source code.

Example 2: A realistic Express route using a connection pool

npm install express mysql2
import express from "express";
import mysql from "mysql2/promise";

const pool = mysql.createPool({
  host: "127.0.0.1",
  user: "app_user",
  password: process.env.DB_PASSWORD,
  database: "shop",
  connectionLimit: 10,
});

const app = express();

app.get("/products/:id", async (req, res) => {
  try {
    const [rows] = await pool.query(
      "SELECT id, name, price FROM products WHERE id = ?",
      [req.params.id]
    );
    if (rows.length === 0) {
      return res.status(404).json({ error: "Product not found" });
    }
    res.status(200).json(rows[0]);
  } catch (err) {
    console.error("Database query failed:", err);
    res.status(500).json({ error: "Internal server error" });
  }
});

const port = process.env.PORT ?? 3000;
app.listen(port, () => console.log(`Server listening on port ${port}`));

The pool is created once, outside the route handler, when the server starts. Every request borrows an idle connection from the pool, runs its query, and returns the connection automatically when the promise resolves — there’s no manual checkout/release code to write. The ? placeholder keeps the user-supplied req.params.id out of the raw SQL string, which is what protects this route from SQL injection.

Example 3: MongoDB with the official driver

npm install mongodb
import { MongoClient } from "mongodb";

const client = new MongoClient(
  process.env.MONGO_URI ?? "mongodb://127.0.0.1:27017"
);

async function main() {
  try {
    await client.connect();
    const db = client.db("shop");
    const products = db.collection("products");

    const result = await products.insertOne({ name: "Keyboard", price: 45.0 });
    console.log("Inserted document id:", result.insertedId);

    const found = await products.findOne({ name: "Keyboard" });
    console.log(found);
  } finally {
    await client.close();
  }
}

main();

Output:

Inserted document id: 6512f3a1c9e77a0012ab34cd
{
  _id: new ObjectId('6512f3a1c9e77a0012ab34cd'),
  name: 'Keyboard',
  price: 45
}

MongoDB has no schema or SQL — you insert plain JavaScript objects, and the driver serializes them to BSON on the wire. The finally block guarantees client.close() runs even if an insert or query throws, which prevents connection leaks.

How It Works Step by Step

  • You call pool.query(sql, params). The driver formats the SQL with the parameters escaped, then hands the promise’s resolution to a callback registered internally — your code does not block here.
  • The formatted command is written to the TCP socket. Control returns to the event loop immediately; other pending requests, timers, and I/O continue to be processed.
  • The database server executes the query and writes bytes back over the same socket.
  • libuv detects readable data on the socket during the event loop’s poll phase and fires the socket’s data event.
  • The driver buffers and parses the incoming bytes into rows (or BSON documents for MongoDB) and resolves your promise with the result.
  • Your await resumes, and the connection is returned to the pool for the next caller — all without ever blocking the single JavaScript thread.

Common Mistakes

Building SQL with string concatenation

This is the classic SQL injection vulnerability: a value from user input is spliced directly into the query text.

const userId = req.query.id;
const [rows] = await pool.query(
  "SELECT * FROM users WHERE id = " + userId
);

If userId is something like 1 OR 1=1, the attacker can read or modify rows they should never see. Always use a placeholder and pass the value separately so the driver escapes it:

const userId = req.query.id;
const [rows] = await pool.query(
  "SELECT * FROM users WHERE id = ?",
  [userId]
);

Opening a new connection on every request

Creating a fresh connection inside a route handler pays the full TCP-and-authentication handshake cost on every single request, and under load it can exhaust the database server’s max-connections limit.

app.get("/products", async (req, res) => {
  const connection = await mysql.createConnection(dbConfig);
  const [rows] = await connection.query("SELECT * FROM products");
  res.json(rows);
});

Create one pool at startup (as in Example 2) and reuse it for every request instead.

Forgetting to handle rejected promises

An await outside a try/catch means a database error (a dropped connection, a bad query) becomes an unhandled promise rejection, which can crash the process in modern Node. Always wrap query calls in try/catch inside request handlers, as shown in Example 2, and respond with an appropriate error status instead of letting the request hang or the process die.

Never closing the pool or client

A script that opens a connection or pool but never calls end()/close() will hang instead of exiting, because open sockets keep the event loop alive. Always close what you open, ideally in a finally block.

Best Practices

  • Always use a connection pool for servers; reserve createConnection() for one-off scripts.
  • Never build SQL with string concatenation or template literals — always use parameterized placeholders (? or named parameters, depending on the driver).
  • Read credentials from environment variables (process.env.DB_PASSWORD) or a .env file loaded with dotenv or node --env-file, never hardcode them.
  • Wrap every query in try/catch and log or report failures — don’t let a database error silently vanish.
  • Close pools and clients on graceful shutdown (listen for SIGTERM) so in-flight queries finish before the process exits.
  • For anything beyond a handful of queries, consider an ORM/query builder such as Prisma, Sequelize, or Knex — they add migrations, schema types, and reduce hand-written SQL, at the cost of an extra abstraction layer.
  • Index the columns you filter and join on; a driver cannot make up for a missing database index.

Practice Exercises

  • Install mysql2, create a local products table, and write a script that connects with a pool, inserts three rows, and then selects and prints all rows ordered by price.
  • Take the Express route from Example 2 and add a POST /products route that inserts a new product using a parameterized INSERT statement, returning the new row’s id with status 201.
  • Install mongodb, connect to a local MongoDB instance, and write a script that inserts five documents into an orders collection, then uses find() with a filter to print only the orders over a given total.

Summary

  • Node.js has no built-in database client — you install a driver such as mysql2, pg, or mongodb from npm.
  • Drivers are async by nature: queries return promises and never block the event loop while waiting on the network.
  • Use a connection pool, created once at startup, instead of opening a new connection per request.
  • Always parameterize queries — never concatenate user input into SQL strings.
  • Wrap queries in try/catch, keep credentials in environment variables, and close pools/clients on shutdown.
  • The same async, pooled pattern applies whether you’re talking to a relational database like MySQL or a document store like MongoDB.