Every Node service that talks to a database is really running two systems at once: the database itself, and a small piece of infrastructure inside the Node process whose only job is managing how that connection gets used.
That second system - the connection pool - is easy to overlook because a driver like pg or an ORM like Prisma hides it behind a simple .query() call, but almost every real production issue in this section (exhausted pools, serverless cold-connection storms, N+1 queries, transaction leaks) traces back to how that pool actually behaves.
Databases Basics shows the code for using a pool correctly; this page is about why pooling exists at all, and where drivers, ORMs, and the event loop each sit in the architecture around it.
A Node process doesn't open a new database connection per query - it maintains a small, reused set of open connections (a pool) and borrows one for the duration of each query, because opening a connection is orders of magnitude more expensive than using an already-open one.
Insight: Pool size, not raw driver speed, is usually the actual concurrency limit on a database-backed Node service - misunderstanding that leads to either starved requests (pool too small) or an overwhelmed database (pool too large, or one per process in a multi-instance deployment).
When to Use: Any Node service issuing more than a handful of queries per second, any serverless deployment where connection churn is the actual risk, and any codebase choosing between a raw driver and an ORM for a new service.
Limitations/Trade-offs: Pooling adds a layer of state (checked-out vs. idle connections, timeouts, leaks) that a naive per-request connection wouldn't have; ORMs add convenience and type safety at the cost of some control over the exact SQL that runs.
Related Topics: transaction management, N+1 query patterns, serverless connection limits, the repository pattern, connection pool sizing.
Opening a database connection is not like opening a file - it involves a TCP handshake with the database server, often a TLS negotiation on top of that, and then an authentication exchange before a single query can run.
That whole sequence can take tens of milliseconds, which is enormous compared to the query itself, so opening a fresh connection for every single request would mean paying that cost - and holding the database's own limited connection slots open for it - constantly.
A connection pool solves this the way a shared taxi rank solves the "buy a car for every trip" problem: a fixed number of connections are opened once, kept alive, and handed out to whoever needs one next, returned to the pool (not closed) when the query finishes.
// One pool per process, created once - not one connection per requestconst pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });const { rows } = await pool.query("SELECT id FROM users WHERE id = $1", [id]);// pool.query() borrows a connection, runs the query, and returns it automatically
Sitting above the pool is a driver - pg for Postgres, the MongoDB driver, ioredis for Redis - a library that speaks the database's actual wire protocol: the specific binary or text format that server expects over the socket.
An ORM (Prisma, Drizzle) is a further layer on top of a driver, not a replacement for one - it still opens the same kind of pooled connections through the underlying driver, but it adds a schema-aware, typed API and generates the SQL (or equivalent) for you instead of you writing it by hand.
The event loop's role here is easy to misjudge: Node's non-blocking I/O model means a query doesn't freeze the process while it waits for the database to respond, but the connection itself is still a single, ordered channel - most database wire protocols process one request at a time per socket, so a pool of ten connections really does mean at most ten queries in flight at once, not unlimited concurrency.
That's why pool size is a concurrency limit you choose, not a performance dial to maximize - a pool of max: 10 means the eleventh concurrent query request waits in a queue for a connection to free up, no matter how fast the event loop itself is running.
Sizing that limit too small starves the app under load - requests queue for a connection even though the database itself has spare capacity; sizing it too large just moves the bottleneck to the database server, which has its own hard cap on total connections and degrades (or refuses new ones) once that cap is hit.
The math gets sharper in a multi-instance deployment: if the database allows 100 connections total and five Node instances each open a pool of max: 30, that's 150 possible connections against a 100-connection ceiling - a configuration that works fine at low traffic and fails exactly when load increases enough for every instance to reach for its full pool at once.
Transactions add a second layer of state on top of pooling: a transaction has to run on one specific borrowed connection from BEGIN to COMMIT/ROLLBACK, because the database tracks transaction state per-connection, not per-query - which is why transaction code explicitly checks out a client and passes it through, rather than calling pool.query() for each statement inside it.
Serverless environments push connection pooling to its breaking point, because the pool's core assumption - a long-lived process that opens connections once and reuses them - doesn't hold when each invocation may spin up a fresh process with an empty pool.
At real scale, a burst of concurrent serverless invocations can each try to open their own small pool simultaneously, producing a spike of new connections that can exceed the database's total limit in seconds - the standard fix is an external connection pooler (PgBouncer, or a managed equivalent like Supabase's pooler or Neon's) that sits between the database and every serverless instance, multiplexing many logical application connections onto a smaller, stable number of real database connections. Connection Pool Tuning covers this sizing math and the serverless-specific patterns in depth.
The driver-vs-ORM choice is really a question of where you want control to live: a raw driver gives full visibility into exactly what SQL runs, at the cost of writing and maintaining it by hand; an ORM generates that SQL from a schema and typed query builder, trading some of that visibility for speed of development and compile-time safety - but it can also hide expensive query patterns behind convenient-looking code, most notoriously the N+1 query problem, where fetching a list and then lazily fetching each item's related data turns one intended query into N+1 round trips.
Neither a driver nor an ORM should be called directly from route handlers in anything beyond a small service - the common pattern is a repository or data-access layer sitting between application logic and the database client, so the database technology (or even driver-vs-ORM choice) can change without rewriting every handler that touches data. Repository Pattern covers that boundary in detail.
Approach
Strength
Weakness
Best Fit
Raw driver (pg, MongoDB driver)
Full control over exact queries; minimal abstraction overhead
You write and maintain SQL/queries by hand; less compile-time safety
Performance-critical paths, complex reporting SQL
Schema-first ORM (Prisma)
Strong typing generated from a schema, migrations built in
Generated queries can hide N+1 patterns; less control over exact SQL
Greenfield APIs prioritizing developer velocity and type safety
SQL-first ORM (Drizzle)
Typed query builder that stays close to actual SQL
Smaller ecosystem/maturity than Prisma at present
Teams wanting typed queries without losing SQL-level visibility
External pooler (PgBouncer, managed poolers)
Absorbs connection storms; decouples app instance count from DB connection limits
Added infrastructure piece; transaction-mode pooling has its own caveats
"The event loop makes database queries fully parallel." The event loop makes the process non-blocking while waiting, but each pooled connection still processes one query at a time - true concurrency is capped by pool size, not by how many queries the event loop can juggle.
"A bigger connection pool is always safer." Past a point it just shifts the bottleneck to the database server's own connection ceiling, which every application instance shares - oversized pools across several instances are a common cause of "database refusing connections" incidents.
"An ORM replaces the driver and the pool." It sits on top of both - Prisma and Drizzle still open pooled connections through an underlying driver; the ORM only changes how queries get written and typed, not the connection architecture underneath.
"Serverless just needs a bigger pool per instance to handle load." More connections per instance makes the connection-storm problem worse, not better, because each concurrent invocation opens its own pool - the fix is a shared external pooler, not a larger per-instance max.
"Using an ORM means I don't need to think about N+1 queries." ORMs make N+1 patterns easier to write accidentally, not impossible to write - lazy relation loading inside a loop generates the same excess round trips an ORM was supposed to help avoid.
Why does Node use a connection pool instead of one connection per request?
Opening a database connection involves a TCP handshake, often TLS, and authentication - a sequence that's slow compared to running a query. A pool opens a small set of connections once and reuses them, avoiding that cost on every request.
What's the difference between a database driver and an ORM?
A driver (like pg) speaks the database's wire protocol directly and exposes a low-level query API. An ORM (like Prisma or Drizzle) sits on top of a driver, generating queries from a schema or typed query builder instead of you writing SQL by hand - it doesn't replace the driver or its pool.
Does Node's non-blocking I/O mean a pool of 10 connections can handle unlimited concurrent queries?
No - most database wire protocols process one query at a time per connection, so a pool of 10 really does cap concurrent in-flight queries at 10. The event loop keeps the process free while waiting, but the pool caps how many queries can be "in flight" against the database simultaneously.
How should I decide what to set a pool's `max` size to?
Base it on expected concurrent query load per instance, divided against how many instances will run simultaneously and the database's total connection ceiling - not on CPU core count or a round default number. Connection Pool Tuning covers the sizing math.
Why do transactions need special handling beyond `pool.query()`?
A transaction's state (BEGIN through COMMIT/ROLLBACK) is tracked per physical connection by the database, not per query - so every statement in a transaction has to run on the same checked-out connection, which is why transaction code explicitly borrows a client instead of calling pool.query() repeatedly.
Why is serverless especially hard on connection pooling?
A pool assumes a long-lived process that opens connections once and reuses them across many requests. Serverless invocations can spin up fresh processes frequently, so a burst of concurrent invocations can each open their own pool at once, producing a connection spike that can exceed the database's limit.
What actually is an N+1 query problem?
Fetching a list of N items and then separately fetching each item's related data one at a time, turning what should be one or two queries into N+1 round trips - it's most common with ORM relation-loading used carelessly inside a loop.
Should application code call the database driver or ORM directly?
In anything beyond a small service, no - a repository or data-access layer typically sits between route handlers and the driver/ORM, so the persistence choice can change without rewriting business logic. See Repository Pattern.
Is a document database (MongoDB) connection-pooled the same way as a relational one?
Yes, conceptually - the MongoDB driver also maintains a pool of reused connections rather than opening one per operation, for the same reason: connection setup is expensive relative to running an operation.
What's the biggest beginner mistake with database connections in Node?
Creating a new connection (or a new pool) per request instead of one shared pool per process - it defeats the entire purpose of pooling and can exhaust the database's connection limit under even modest load.