All posts

standard Node.js-to-PostgreSQL connection setup

Standard Node.js Connection to PostgreSQL: The Differences Between Client, Connection, Session, and Pool

nodejs-pg-standard-connection

Connecting a Node.js application to PostgreSQL may seem simple at first: we create a Client, call the connect() method, execute a Query, and close the connection at the end.

This approach is perfectly acceptable for a short script. However, when our application is a real API that receives multiple concurrent Requests, the situation becomes more complex. In such an application, we need to understand exactly what Client, Connection, Session, and Pool are, how they relate to one another, and where each one belongs in the architecture.

The recommended model for most Node.js APIs is: create one shared Pool per application Process, use pool.query() for independent Queries, and obtain a Client directly from the Pool only when multiple operations must run on a specific Session.


The Problem with Creating a Connection for Every Request

Suppose we create a new Client for every Request in an Express application:

import express from "express";
import { Client } from "pg";

const app = express();

app.get("/users", async (_request, response, next) => {
  const client = new Client({
    connectionString: process.env.DATABASE_URL,
  });

  try {
    await client.connect();

    const result = await client.query(
      "SELECT id, username, email FROM users",
    );

    response.json(result.rows);
  } catch (error) {
    next(error);
  } finally {
    await client.end();
  }
});

From a functional perspective, this code makes sense: it establishes a connection, executes the Query, and closes the connection at the end. The problem begins when this process is repeated for every single Request.

For each Request, roughly the following steps occur:

  1. An Object of type Client is created.
  2. A real Connection to PostgreSQL is established.
  3. For a network connection, a TCP handshake takes place.
  4. If TLS is enabled, a TLS handshake also takes place.
  5. PostgreSQL authenticates the user.
  6. A PostgreSQL Session is created.
  7. The Query is executed.
  8. The Connection is closed.
  9. The Session on the PostgreSQL side ends.
A simplified view of this process:
Request
Create a Client
Establish a Connection
Authentication and creation of a Session
Execute the Query
Close the Connection

Creating a new Connection has a cost. This cost is not limited to the execution time of the SELECT; establishing communication, authentication, and creating state on the PostgreSQL side are also part of it.

Creating a new Connection has a cost. This cost is not limited to the execution time of the SELECT; establishing the connection, authentication, and creating state on the PostgreSQL side are also part of it.

If 100 Requests arrive at nearly the same time, the application attempts to repeat this process many times:

Request 1   → Connect → Query → Disconnect
Request 2   → Connect → Query → Disconnect
Request 3   → Connect → Query → Disconnect
...
Request 100 → Connect → Query → Disconnect

This model has three main problems:

  • Response time increases.
  • PostgreSQL continuously creates and destroys new Connections and Sessions.
  • Controlling the number of concurrent Connections becomes difficult.

For a Migration, Seed, CLI tool, or one-time script, this model may be appropriate. However, for a Web API that continuously receives Requests, Connections should be reused. This is exactly the problem that a Connection Pool solves.


The Four Core Concepts of Connecting to PostgreSQL

Before examining the Pool, we need to distinguish these four concepts:

Client
Connection
Session
Pool

These concepts are related, but they are not synonymous.

A simplified model of their relationship:
Node.js code
Client
Connection
PostgreSQL Session

And when a Pool is introduced into the architecture:

Pool
├── Client 1
│   └── Connection 1
│       └── Session 1
├── Client 2
│   └── Connection 2
│       └── Session 2
└── Client 3
    └── Connection 3
        └── Session 3

Now let us examine each one more closely.


What Is a Client?

In the pg package, also known as node-postgres, a Client is essentially a JavaScript Object inside the Node.js application.

import { Client } from "pg";

const client = new Client({
  connectionString: process.env.DATABASE_URL,
});

At this point, only the Object has been created. There is not necessarily an active Connection to PostgreSQL yet.

When connect() is called, the Client attempts to establish the connection:

await client.connect();

We can then execute a Query:

const result = await client.query(
  "SELECT NOW() AS current_time",
);

And finally, close the connection completely:

await client.end();

Therefore, a Client:

  • Is an Object inside the Node.js application.
  • Is part of the pg library.
  • Is not PostgreSQL itself.
  • Is not the network channel itself.
  • Is the application's interface for creating and controlling a Connection.
  • Converts and sends the Query using the protocol understood by PostgreSQL.
  • Receives PostgreSQL's response and converts it into JavaScript structures.

Mental model:

Application Code
      ↓
pg.Client Object
      ↓
Connection
      ↓
PostgreSQL

A Client can be thought of as a controller. The application interacts with this Object, while the Client manages the details of communicating with PostgreSQL.


What Is a Connection?

A Connection is the actual communication channel between the Node.js application and PostgreSQL.

If the application and database communicate over a network, this Connection is usually a TCP/IP Connection:

Node.js
   ↕ TCP/IP Connection
PostgreSQL

If both Processes are running on the same Unix-like system, a Unix Domain Socket can also be used:

Node.js
   ↕ Unix Domain Socket
PostgreSQL

Therefore, a Connection may be one of the following:

TCP/IP Connection
Unix Domain Socket

The Connection is responsible for transferring data. When we execute this code:

await client.query(
  "SELECT id, username FROM users WHERE id = $1",
  [userId],
);

The Client prepares the SQL command and its parameters according to the PostgreSQL protocol and sends them through the Connection. PostgreSQL returns the result through the same Connection.

The important distinction is:

DescriptionConcept
The class provided by the pg package from which an object such as client is createdClient
The actual network communication between the client and the PostgreSQL serverConnection

There is no need to go deeply into TCP/IP, Unix Sockets, TLS, and Handshakes at this point. For now, it is enough to know that a Client communicates with PostgreSQL through a Connection.


What Is a PostgreSQL Session?

When a Connection is successfully established and the initial connection and authentication steps are completed, PostgreSQL creates a Session for that connection.

A Session is the state maintained by PostgreSQL for the lifetime of the Connection.

Client
   ↓
Connection
   ↓
PostgreSQL Session

A Session can retain information and state such as:

  • The user used to establish the connection
  • The selected database
  • The current Transaction
  • Settings applied with SET
  • Temporary Tables
  • Defined Cursors
  • Channels being monitored with LISTEN
  • Session-level Advisory Locks
  • Prepared Statements
  • Certain settings related to date formatting, Timezone, or Encoding

For example, if the following command is executed on a Session:

SET TIME ZONE 'UTC';

This setting belongs to that specific Session. Another Session does not necessarily have the same setting.

Or, if we create a Temporary Table:

CREATE TEMP TABLE imported_users (
  id UUID,
  email TEXT
);

This temporary Table exists only within that Session.

A Transaction also belongs to a Session:

BEGIN;

PostgreSQL must know which Transaction the subsequent Queries belong to, and this state is maintained in that same Session.

This point will become very important later:

Each active Connection usually corresponds to one specific Session in PostgreSQL, and Session state is not shared across different Connections.

When the Connection is closed, the Session also ends, and its associated state is destroyed.


What Is a Pool?

A Pool is a manager for a collection of Clients and Connections.

Pool
├── Client 1 → Connection 1 → Session 1
├── Client 2 → Connection 2 → Session 2
└── Client 3 → Connection 3 → Session 3

The Pool itself:

  • Is not a Connection.
  • Is not a Session.
  • Is not a specific Query.
  • Is not a single Client.

A Pool is a management Object inside the application that performs the following tasks:

  • Creates new Clients.
  • Keeps created Connections available.
  • Selects an available Client for executing a Query.
  • Creates a new Client when necessary, up to the configured limit.
  • Keeps released Clients available for reuse.
  • Queues requests that are waiting for a Client.
  • Closes Idle Connections according to its configuration.
  • Manages the overall state of active and available Clients.

Creating a Pool:

import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

A Pool usually does not create all possible Connections immediately during Startup. Connections are created when needed and can be reused by later requests after a Query finishes.

Suppose the Pool has three Connections:

Pool
├── Client 1 → available
├── Client 2 → busy
└── Client 3 → available

When a new Query arrives, the Pool selects one of the available Clients:

Execution path of the new Query:
New Query
Pool
Client 1
PostgreSQL

After the Query finishes, the Client is returned to the Pool:

After the Query finishes:
Query completed
Client 1 became available
Ready to be reused

This eliminates the need to establish an entirely new Connection for every Query.


The Complete Relationship Between Pool, Client, Connection, and Session

A more precise model:

Node.js Process
│
└── Pool
    │
    ├── Client 1
    │   └── TCP/IP or Unix Socket Connection 1
    │       └── PostgreSQL Session 1
    │
    ├── Client 2
    │   └── TCP/IP or Unix Socket Connection 2
    │       └── PostgreSQL Session 2
    │
    └── Client 3
        └── TCP/IP or Unix Socket Connection 3
            └── PostgreSQL Session 3

Each layer has a different responsibility:

ConceptLocationResponsibility
PoolInside Node.jsManages multiple Clients and Connections
ClientInside Node.js and the pg libraryProvides the application interface for controlling a Connection and executing Queries
ConnectionBetween Node.js and PostgreSQLTransfers data through TCP/IP or a Unix Socket
SessionOn the PostgreSQL sideMaintains connection state such as Transactions, settings, and Temporary Tables

A simple analogy:

ConceptDescription
PoolA manager for a collection of connections
ClientA tool for managing and using a connection
ConnectionThe actual communication established between the application and PostgreSQL
SessionThe state and interaction associated with that connection on the PostgreSQL side

The analogy is not perfect, but it is useful for distinguishing the concepts.


One Pool per Request or per Process?

A Pool should not be created for every Request.

Incorrect model:

app.get("/users", async (_request, response) => {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const result = await pool.query(
    "SELECT id, username FROM users",
  );

  await pool.end();

  response.json(result.rows);
});

In this model, a new Pool is created and then closed for every Request. This effectively removes the benefits of Connection Pooling.

Correct model:

Node.js Process
│
├── Request A ─┐
├── Request B ─┼──> one shared Pool
├── Request C ─┤
└── Request D ─┘

In other words:

In each Node.js Process, one Pool is usually created, and all Requests handled by that Process use it.

For example:

// database.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

Then, the same Pool is imported in different parts of the application:

import { pool } from "./database.js";

What Does Process Mean?

When the application is started like this:

node dist/server.js

We have one Node.js Process:

Process 1
└── Pool 1

However, if we run four instances of the application with PM2:

pm2 start dist/server.js -i 4

We will have four independent Processes:

Process 1 → Pool 1
Process 2 → Pool 2
Process 3 → Pool 3
Process 4 → Pool 4

These four Processes do not share memory. Therefore, the Pool is not shared between them, and each one creates its own Pool.

As a result, the phrase “one Pool per Process” does not mean the entire infrastructure always has only one Pool. If we have multiple Processes, Pods, Containers, or Instances, each one will have its own independent Pool.


The Relationship Between Singleton and Pool

It is sometimes said that a Singleton should be used for connecting to PostgreSQL. On its own, this statement can be misleading because Singleton and Pool do not solve the same problem.

What Does Singleton Control?

Singleton concerns the number of Objects created in the application's memory:

Only one Pool Object should be created in each Process

What Does Pool Control?

Pool concerns the management of multiple Clients and Connections:

Pool structure:
one Pool Object
multiple Clients and Connections

Therefore, these two concepts are not competitors. A common model in a real API looks like this:

Recommended structure:
one shared Pool per Node.js Process
multiple Connections managed inside the Pool

A more precise statement is therefore:

We keep the Pool as one shared instance within each Process, while the Pool itself manages multiple Connections.

What is usually not appropriate is using one permanent shared Client for all Requests:

Communication structure:
all Requests
one Client
one Connection

In this model, there is only one Connection, Queries run on the same Session, and Session-dependent operations may interfere with other Requests. We will examine this issue in greater detail in the article about Transactions.


Why Is a Singleton Class Usually Unnecessary?

We might write a class like this:

import { Pool } from "pg";

export class PostgresSingleton {
  private static instance: PostgresSingleton | undefined;

  public readonly pool: Pool;

  private constructor() {
    this.pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
  }

  public static getInstance(): PostgresSingleton {
    if (!PostgresSingleton.instance) {
      PostgresSingleton.instance = new PostgresSingleton();
    }

    return PostgresSingleton.instance;
  }
}

This code can work, but it is unnecessary in most Node.js projects.

In the usual Module structure, the database file is evaluated once within the Module Graph of that Process, and its Export is reused:

// postgres.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

In another file:

import { pool } from "./postgres.js";

As long as all Imports resolve to the same Module and we do not deliberately create duplicate Modules or separate Loaders, the same Pool is used within the Process.

Creating a Singleton Class can:

  • Make the code more complex.
  • Make testing more difficult.
  • Make replacing the Dependency harder.
  • Complicate Dependency Injection.
  • Add an extra layer without providing a real benefit.

For most projects, one central Module is sufficient:

// database/postgres.ts
export const pool = new Pool(config);

Of course, in architectures that use Dependency Injection, the Pool may be created in the Composition Root and injected into Adapters. The main principle remains unchanged: the number of Pools in each Process should be deliberate and controlled.


The Difference Between pool.query() and pool.connect()

There are two primary ways to execute a Query through a Pool.

Executing an Independent Query with pool.query()

For most independent Queries, it is better to use pool.query() directly:

const result = await pool.query(
  `
    SELECT
      id,
      username,
      email
    FROM users
    WHERE id = $1
  `,
  [userId],
);

Behind the scenes, the Pool:

  1. Finds an available Client.
  2. Creates a new Client if none is available and capacity still remains.
  3. Executes the Query on the selected Client.
  4. Automatically returns the Client to the Pool after the Query finishes.

Model:

The pool.query() process:
pool.query()
Automatically acquire a Client
Execute the Query
Automatically return the Client to the Pool

In this case, we must not call release() ourselves because the Pool handles it automatically.

This method is appropriate for:

  • An independent SELECT
  • An independent INSERT
  • An independent UPDATE
  • An independent DELETE
  • Any operation in which multiple Queries do not need to run on one specific Session

Acquiring a Client with pool.connect()

Sometimes multiple operations must run on one specific Client and Session. In this case, we acquire a Client directly from the Pool:

const client = await pool.connect();

try {
  const userResult = await client.query(
    "SELECT id, username FROM users WHERE id = $1",
    [userId],
  );

  const taskResult = await client.query(
    "SELECT id, title FROM tasks WHERE user_id = $1",
    [userId],
  );

  return {
    user: userResult.rows[0] ?? null,
    tasks: taskResult.rows,
  };
} finally {
  client.release();
}

When we use pool.connect(), returning the Client is our responsibility. For this reason, release() must be placed in a finally block so that it is executed even if an error occurs.

The most important use case for this method is a Transaction:

const client = await pool.connect();

try {
  await client.query("BEGIN");

  // All Queries in the Transaction use this same client

  await client.query("COMMIT");
} catch (error) {
  await client.query("ROLLBACK");
  throw error;
} finally {
  client.release();
}

Because a Transaction belongs to a Session, all of its Queries must be executed on one specific Client. Transactions, BEGIN, COMMIT, and ROLLBACK will be covered in full detail in a separate article.

A Simple Rule

One independent Query:
pool.query()
Multiple operations tied to one Session:
pool.connect()
Use the same Client
client.release()

The Difference Between release() and end()

These two methods must not be confused with one another.

client.release()

When we acquire a Client from a Pool:

const client = await pool.connect();

We Release it at the end:

client.release();

release() usually means:

Releasing the Client:
The Client is no longer busy
It returns to the Pool
The Connection remains open for later use

In other words, the Connection and Session do not necessarily end. They return to the Pool so that the next Query can reuse the same Connection.

client.end()

When we have created an independent Client:

const client = new Client(config);
await client.connect();

Calling end() actually closes the connection:

await client.end();

Result:

Result of closing the Connection:
The Connection is closed
The Session on the PostgreSQL side ends

pool.end()

This method is used to close the entire Pool:

await pool.end();

It is appropriate when the application is shutting down, not at the end of every Request.

Summary:

client.release()
Returns the Client to the Pool
The Connection remains open for reuse
client.end()
Closes the Connection associated with an independent Client
The Session ends
pool.end()
Drains the Pool
The Connections inside the Pool are closed

For an Express and TypeScript project, the following structure can be used:

src/
├── config/
│   └── env.ts
│
├── infrastructure/
│   └── database/
│       └── postgres.ts
│
├── modules/
│   └── users/
│       ├── user.repository.ts
│       ├── user.service.ts
│       ├── user.controller.ts
│       └── user.routes.ts
│
├── app.ts
└── server.ts

Dependency flow:

HTTP Request
    ↓
Controller
    ↓
Service / Use Case
    ↓
Repository
    ↓
Database Adapter
    ↓
Pool
    ↓
PostgreSQL

Controller

The Controller is responsible for HTTP concerns:

  • Receiving input
  • Reading Parameters
  • Sending Status Codes
  • Building the Response
  • Passing Errors to the Error Handler

Service or Use Case

The Service or Use Case is responsible for Application and Business logic:

  • Making decisions about the operation flow
  • Coordinating Repositories
  • Enforcing Business Rules
  • Defining Transaction boundaries for complex operations

Repository

A Repository is responsible for data access for a specific part of the application:

  • users Queries
  • tasks Queries
  • Converting database Rows into the models required by the application

Database Adapter

A Database Adapter is a central layer for communicating with the database Driver:

  • Executing Queries
  • Managing the Pool
  • Checking connection health
  • Graceful Shutdown
  • Logging and Metrics
  • A Transaction helper in more advanced versions

Pool

The Pool manages PostgreSQL Clients and actual Connections.


Implementing a Central Pool

First, install the packages:

npm install pg
npm install --save-dev @types/pg

In versions where Types are provided directly with the package, the need for @types/pg may differ. Check your project configuration and the version being used.

Environment variables:

DATABASE_URL=postgresql://app_user:strong_password@127.0.0.1:5432/app_db
PG_POOL_MAX=10
PG_CONNECTION_TIMEOUT_MS=5000
PG_IDLE_TIMEOUT_MS=30000
APP_NAME=my-api

The central PostgreSQL file:

// src/infrastructure/database/postgres.ts

import {
  Pool,
  type QueryResult,
  type QueryResultRow,
} from "pg";

function parsePositiveInteger(
  value: string | undefined,
  fallback: number,
): number {
  if (!value) {
    return fallback;
  }

  const parsedValue = Number.parseInt(value, 10);

  if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
    return fallback;
  }

  return parsedValue;
}

const databaseUrl = process.env.DATABASE_URL;

if (!databaseUrl) {
  throw new Error("DATABASE_URL is not defined");
}

const pool = new Pool({
  connectionString: databaseUrl,

  // Maximum number of Clients in this Pool
  max: parsePositiveInteger(process.env.PG_POOL_MAX, 10),

  // Maximum time to wait for a Connection to be established
  connectionTimeoutMillis: parsePositiveInteger(
    process.env.PG_CONNECTION_TIMEOUT_MS,
    5_000,
  ),

  // How long an available Client remains in the Pool
  idleTimeoutMillis: parsePositiveInteger(
    process.env.PG_IDLE_TIMEOUT_MS,
    30_000,
  ),

  application_name: process.env.APP_NAME ?? "node-api",
});

pool.on("error", (error) => {
  console.error("Unexpected PostgreSQL pool error", {
    name: error.name,
    message: error.message,
    stack: error.stack,
  });
});

export async function query<
  T extends QueryResultRow = QueryResultRow,
>(
  text: string,
  values: unknown[] = [],
): Promise<QueryResult<T>> {
  const startedAt = performance.now();

  try {
    return await pool.query<T>(text, values);
  } finally {
    const durationMs = performance.now() - startedAt;

    if (durationMs >= 500) {
      console.warn("Slow PostgreSQL query detected", {
        durationMs: Math.round(durationMs),
      });
    }
  }
}

export async function checkDatabaseConnection(): Promise<void> {
  await pool.query("SELECT 1");
}

export async function closeDatabaseConnection(): Promise<void> {
  await pool.end();
}

export function getPoolStatistics() {
  return {
    total: pool.totalCount,
    idle: pool.idleCount,
    waiting: pool.waitingCount,
  };
}

export { pool };

A few points about this file:

  1. The Pool is created only once in the central Module.
  2. Repositories do not need to know the connection configuration directly.
  3. The query() function is the central execution point for independent Queries.
  4. The error listener records unexpected errors from Pool Clients.
  5. SELECT 1 is available for an initial PostgreSQL availability check.
  6. pool.end() is available for application Shutdown.
  7. Pool statistics can be retrieved for Monitoring.
  8. Query parameters are intentionally not written to Logs to prevent sensitive information from leaking.

The precise configuration of max, idleTimeoutMillis, connectionTimeoutMillis, and other Pool options depends on the application's workload and Deployment architecture. There is no need to go deeper into this topic at this point.


Using the Database Adapter in a Repository

A simple Repository for User:

// src/modules/users/user.repository.ts

import {
  query,
} from "../../infrastructure/database/postgres.js";

interface UserRow {
  id: string;
  username: string;
  email: string;
  first_name: string;
  last_name: string;
}

interface CreateUserInput {
  id: string;
  username: string;
  email: string;
  firstName: string;
  lastName: string;
  passwordHash: string;
}

export class UserRepository {
  async findById(id: string): Promise<UserRow | null> {
    const result = await query<UserRow>(
      `
        SELECT
          id,
          username,
          email,
          first_name,
          last_name
        FROM users
        WHERE id = $1
      `,
      [id],
    );

    return result.rows[0] ?? null;
  }

  async findByUsername(
    username: string,
  ): Promise<UserRow | null> {
    const result = await query<UserRow>(
      `
        SELECT
          id,
          username,
          email,
          first_name,
          last_name
        FROM users
        WHERE username = $1
      `,
      [username],
    );

    return result.rows[0] ?? null;
  }

  async create(input: CreateUserInput): Promise<UserRow> {
    const result = await query<UserRow>(
      `
        INSERT INTO users (
          id,
          username,
          email,
          first_name,
          last_name,
          password
        )
        VALUES ($1, $2, $3, $4, $5, $6)
        RETURNING
          id,
          username,
          email,
          first_name,
          last_name
      `,
      [
        input.id,
        input.username,
        input.email,
        input.firstName,
        input.lastName,
        input.passwordHash,
      ],
    );

    const createdUser = result.rows[0];

    if (!createdUser) {
      throw new Error("PostgreSQL did not return the created user");
    }

    return createdUser;
  }
}

In this structure, the Repository does not know how the Pool was created, what the Connection String is, or what happens during Shutdown. It only uses the internal API provided by the Database Adapter.

The benefits of this separation include:

  • Connection configuration is centralized.
  • Logging changes are made in only one file.
  • Health Checks are controlled from a single point.
  • Repositories remain simpler.
  • Mocking the Database Adapter in tests becomes easier.
  • Driver-specific changes are less widely distributed throughout the project.

In larger architectures, instead of directly importing the query function, an Interface can be defined and the Database Adapter can be provided to the Repository through Dependency Injection. However, as a starting point, this central structure is far better than importing and creating a Pool inside every Repository.


Parameterized Queries and Preventing SQL Injection

Never insert user input directly into SQL through String interpolation or Concatenation.

Unsafe Approach

const result = await pool.query(
  `
    SELECT id, username
    FROM users
    WHERE username = '${username}'
  `,
);

Suppose the value of username is controlled by the user. The user can construct an input that changes the structure of the SQL statement.

Correct Approach

const result = await pool.query(
  `
    SELECT id, username
    FROM users
    WHERE username = $1
  `,
  [username],
);

In a parameterized Query, the SQL text and the Values are sent separately:

SQL:
SELECT ... WHERE username = $1

Values:
[username]

PostgreSQL processes the value as Data, not as part of the SQL structure.

For multiple Parameters:

const result = await pool.query(
  `
    SELECT
      id,
      username,
      email
    FROM users
    WHERE email = $1
      AND status = $2
  `,
  [email, "ACTIVE"],
);

Placeholder numbering starts at one:

$1 → email
$2 → ACTIVE

A Note About Identifiers

Parameters such as $1, $2, and so on are for Values, not Table or Column names.

This does not work:

await pool.query(
  "SELECT * FROM $1",
  [tableName],
);

If a Table or Column name must be dynamic, it is better to:

  • Use a clearly defined Allowlist.
  • Avoid constructing Identifiers from unrestricted user input.
  • Use an appropriate tool for escaping Identifiers.
  • Reconsider the Query design so that unnecessary Dynamic SQL can be eliminated.

Checking the Connection During Startup

If the API cannot perform its core function without PostgreSQL, it is better to verify the database connection before the HTTP Server starts listening.

// src/server.ts

import app from "./app.js";
import {
  checkDatabaseConnection,
} from "./infrastructure/database/postgres.js";

const port = Number(process.env.PORT ?? 3000);

async function bootstrap(): Promise<void> {
  await checkDatabaseConnection();

  app.listen(port, () => {
    console.log(`HTTP server is running on port ${port}`);
  });
}

bootstrap().catch((error) => {
  console.error("Application startup failed", error);
  process.exit(1);
});

Flow:

Process starts
    ↓
Validate environment variables
    ↓
SELECT 1
    ↓
Connection successful?
  ├── Yes → Start the HTTP Server
  └── No  → Exit the Process with an error

The benefit of this approach is that the application does not appear to be Up while no Queries can actually be executed.

Of course, this is an architectural decision. Some services may be able to provide part of their functionality without the database or may prefer to Retry the connection later. However, for most APIs that depend on PostgreSQL, Failing Fast during Startup provides behavior that is easier to understand and monitor.


Graceful Shutdown

When the Process receives a termination signal, it should not exit abruptly. It should:

  1. Stop accepting new Requests.
  2. Allow in-progress Requests time to finish.
  3. Close the Pool.
  4. End the PostgreSQL Connections.
  5. Exit the Process.

Common signals:

SignalUsually sent by
SIGINTPressing Ctrl + C in the terminal
SIGTERMTools such as PM2, Docker, Kubernetes, or the operating system

Implementation:

// src/server.ts

import app from "./app.js";
import {
  checkDatabaseConnection,
  closeDatabaseConnection,
} from "./infrastructure/database/postgres.js";

const port = Number(process.env.PORT ?? 3000);

async function bootstrap(): Promise<void> {
  await checkDatabaseConnection();

  const server = app.listen(port, () => {
    console.log(`HTTP server is running on port ${port}`);
  });

  let isShuttingDown = false;

  async function shutdown(signal: string): Promise<void> {
    if (isShuttingDown) {
      return;
    }

    isShuttingDown = true;

    console.log(`${signal} received. Starting graceful shutdown.`);

    server.close(async (httpError) => {
      try {
        await closeDatabaseConnection();
      } catch (databaseError) {
        console.error(
          "Failed to close PostgreSQL pool",
          databaseError,
        );
      }

      if (httpError) {
        console.error(
          "HTTP server shutdown failed",
          httpError,
        );

        process.exit(1);
      }

      console.log("Application stopped successfully");
      process.exit(0);
    });
  }

  process.once("SIGINT", () => {
    void shutdown("SIGINT");
  });

  process.once("SIGTERM", () => {
    void shutdown("SIGTERM");
  });
}

bootstrap().catch((error) => {
  console.error("Application startup failed", error);
  process.exit(1);
});

In Production, an emergency Shutdown Timeout is usually also configured so that if a Request or Resource remains stuck indefinitely, the Process is forcefully terminated after a specified period. Its value should be coordinated with the Grace Period configured in the Deployment tool.

Why Do We Not Call pool.end() After Every Query?

Because pool.end() is intended to end the lifetime of the Pool. If it is called after every Request, the next Query can no longer use the Pool, and we would have to create a new Pool again.

End of a Query:
The Client returns to the Pool
End of the Process:
pool.end()

Common Mistakes

1. Creating a Pool Inside a Route or Controller

app.get("/users", async (_request, response) => {
  const pool = new Pool(config);
  const result = await pool.query("SELECT * FROM users");

  response.json(result.rows);
});

Problem: a new Pool is created for every Request.


2. Creating a Pool in Every Repository

export class UserRepository {
  private readonly pool = new Pool(config);
}

If we have ten Repositories, we may create ten independent Pools inside one Process. Each Pool can also create multiple Connections, causing the total number of Connections to increase rapidly.

Correct model:

Database access structure:
all Repositories
shared Database Adapter
one Pool in the same Process

3. Using One Permanent Client for All Requests

const client = new Client(config);
await client.connect();

export { client };

This can be intentional for certain dedicated Sessions, such as LISTEN, but it is not appropriate for all Queries in a general-purpose API.

Problems:

  • There is only one Connection.
  • Queries run on one shared Session.
  • Session-bound operations can affect other Requests.
  • Transactions may interfere with one another in dangerous ways.
  • A failure of that single Connection affects all database access.

4. Forgetting to Call release()

const client = await pool.connect();

await client.query("SELECT * FROM users");

// client.release() was forgotten

This Client does not return to the Pool and remains marked as busy. Repeating this mistake causes a Client Leak, and new Requests begin waiting for a Connection.

Correct approach:

const client = await pool.connect();

try {
  await client.query("SELECT * FROM users");
} finally {
  client.release();
}

5. Using pool.connect() Without a Reason

There is no need to manually acquire a Client for one independent Query:

const client = await pool.connect();

try {
  return await client.query(
    "SELECT * FROM users WHERE id = $1",
    [userId],
  );
} finally {
  client.release();
}

A simpler version:

return pool.query(
  "SELECT * FROM users WHERE id = $1",
  [userId],
);

pool.connect() is valuable when we genuinely need a fixed Client and Session for multiple operations.


6. Executing a Transaction with Multiple pool.query() Calls

This model is dangerous:

await pool.query("BEGIN");
await pool.query("UPDATE accounts SET ...");
await pool.query("COMMIT");

The Pool does not guarantee that all these commands will be executed on the same Client. A Transaction must be executed with a Client acquired through pool.connect().


7. Closing the Pool After Every Request

await pool.query("SELECT ...");
await pool.end();

pool.end() is for shutting down the entire Application, not for ending a Query.


8. Building SQL with User Input

const sql = `
  SELECT *
  FROM users
  WHERE email = '${email}'
`;

This approach exposes the application to SQL Injection. Use parameterized Queries instead.


9. Logging Sensitive Query Values

console.log({
  text,
  values,
});

The values array may contain:

  • Password hashes
  • Refresh Tokens
  • Email addresses
  • Phone numbers
  • Personal information
  • Secrets

For safer Logging, record the Query Name, execution time, Row count, and Error Code. Values should only be included in Logs under a clearly defined Redaction policy.


10. Assuming a Singleton Is Shared Across Processes

If we have four PM2 Processes, each one has independent memory:

Process 1 → its own Singleton → its own Pool
Process 2 → its own Singleton → its own Pool
Process 3 → its own Singleton → its own Pool
Process 4 → its own Singleton → its own Pool

A Singleton only has meaning within the same Process.


Guide to Choosing Client or Pool

ScenarioAppropriate choice
Typical Web APIOne shared Pool per Process
One independent Querypool.query()
Multiple Queries inside a Transactionpool.connect() and one fixed Client
Session-dependent operationsA fixed Client
Short scriptA direct Client or a short-lived Pool
MigrationA Client or a short-lived Pool
SeedA Client or a short-lived Pool
CLIA Client or a short-lived Pool
LISTEN / NOTIFYA dedicated, long-lived Client
Long-running Cursor or Streaming operationA dedicated Client acquired from the Pool or an independent Client
Application Shutdownpool.end()

The main rule:

Independent Query?
→ pool.query()

Need a fixed Session?
→ pool.connect()

Is it a short, one-time program?
→ Direct Client or short-lived Pool

Is the application shutting down?
→ pool.end()

Summary

The standard model for connecting a Node.js API to PostgreSQL usually looks like this:

Node.js Process
│
└── one shared Pool
    ├── Client 1
    │   └── Connection 1
    │       └── PostgreSQL Session 1
    ├── Client 2
    │   └── Connection 2
    │       └── PostgreSQL Session 2
    └── Client N
        └── Connection N
            └── PostgreSQL Session N

Key points:

  • Do not create a new Pool for every Request.
  • Usually, maintain one shared Pool per Process.
  • Pool and Singleton are not competing concepts.
  • A Pool manages multiple Clients and Connections.
  • A Client is an Object inside the Node.js application.
  • A Connection is the actual communication channel.
  • A Session is the state maintained on the PostgreSQL side.
  • Use pool.query() for an independent Query.
  • Use pool.connect() for operations that require a fixed Session.
  • Always release a Client acquired from the Pool inside a finally block.
  • release() does not close the Connection; it returns the Client to the Pool.
  • client.end() closes the Connection of an independent Client.
  • pool.end() is used when shutting down the entire Pool.
  • Use parameterized Queries.
  • Manage the Pool through a central Database Adapter.
  • Verify the connection during Startup.
  • Cleanly shut down the HTTP Server and Pool during Shutdown.
  • Remember that every Process, Pod, or Container has its own independent Pool.

Useful Documentation