Node.js REST API Performance: Connection Pooling & TypeScript

Optimize Node.js 22 REST APIs with connection pooling, TypeScript strictness, and query optimization. Practical patterns for scaling database throughput.

0

Building REST APIs that handle thousands of concurrent requests without melting your database is not about frameworks or magic. It’s about understanding where latency hides and systematically eliminating it. After years of scaling Node.js backends in production, I’ve seen the same bottlenecks appear repeatedly: connection exhaustion, N+1 queries, missing indexes, and loose TypeScript configurations that hide subtle bugs at runtime.

This guide walks through concrete patterns that have cut latency by 40-60% in systems I’ve worked on. We’ll focus on connection pooling, TypeScript best practices, query optimization, and load testing. The goal is practical: you should be able to apply these patterns to your API today.

Why Connection Pooling Matters

Most developers don’t think about database connections until they’re paged at 2am because the API is returning 503s. By then, the connection pool is exhausted.

Each database connection carries overhead: TCP handshake, authentication, memory allocation. Creating a new connection for every request is expensive. Connection pooling reuses connections across requests, dramatically reducing latency and resource consumption.

Without pooling, a moderately busy API might create and destroy thousands of connections per minute. With pooling, you maintain a fixed set of warm connections. The difference in p99 latency is often 500ms versus 50ms.

Setting Up Connection Pooling with Node.js

For PostgreSQL, I use pg with a connection pool. For MySQL, mysql2 or the native driver with pooling. Here’s a production-grade setup:

import { Pool, QueryResult } from 'pg';

const pool = new Pool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
  application_name: 'api-service',
});

pool.on('error', (err) => {
  console.error('Unexpected error on idle client', err);
});

export async function query<T = any>(sql: string, values?: any[]): Promise<QueryResult<T>> {
  const start = Date.now();
  try {
    const result = await pool.query(sql, values);
    const duration = Date.now() - start;
    if (duration > 1000) {
      console.warn(`Slow query (${duration}ms): ${sql.substring(0, 80)}`);
    }
    return result;
  } catch (error) {
    console.error('Query error:', error);
    throw error;
  }
}

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

Key settings explained:

  • max: 20 – Tune this based on your database’s max_connections and your service count. Start conservative; increase if you see connection exhaustion warnings.
  • idleTimeoutMillis: 30000 – Close idle connections after 30 seconds to prevent connection leaks.
  • connectionTimeoutMillis: 2000 – Fail fast if the database is unreachable; don’t hang the API.
  • application_name – Helps identify your service in database logs and monitoring tools.

The query wrapper logs slow queries. In production, ship these metrics to your observability platform (Datadog, New Relic, etc.). Slow queries are the first sign of trouble.

TypeScript Strictness for Reliability

TypeScript doesn’t prevent runtime errors by default. You need to enable strict mode to catch entire classes of bugs before they reach production.

In tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "target": "ES2020",
    "module": "ESNext"
  }
}

With strict mode enabled, you catch null reference errors, missing return statements, and type mismatches at compile time. In a high-throughput system, these bugs cascade into 500s and cascading failures.

Here’s a typed endpoint that enforces safety:

import express, { Request, Response } from 'express';
import { query } from './db';

interface User {
  id: number;
  email: string;
  created_at: Date;
}

app.get('/users/:id', async (req: Request, res: Response): Promise<void> => {
  try {
    const userId = parseInt(req.params.id, 10);
    if (isNaN(userId)) {
      res.status(400).json({ error: 'Invalid user ID' });
      return;
    }

    const result = await query<User>(
      'SELECT id, email, created_at FROM users WHERE id = $1',
      [userId]
    );

    if (result.rows.length === 0) {
      res.status(404).json({ error: 'User not found' });
      return;
    }

    res.json(result.rows[0]);
  } catch (error) {
    console.error('Error fetching user:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

Notice: generic type on query result, explicit return type on the handler, null checks before accessing data. These patterns prevent silent failures and make debugging faster.

Query Optimization and Indexes

Connection pooling is useless if your queries are slow. A 100ms query with 20 concurrent connections will exhaust your pool in seconds.

Start with the basics: index frequently filtered and joined columns.

-- Good indexes
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);

-- Composite index for common queries
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);

Always check your query plan before deploying:

EXPLAIN ANALYZE
SELECT u.id, u.email, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.email;

Look for sequential scans on large tables (bad), missing indexes (bad), and high execution time (bad). Good plans use index scans and have reasonable costs.

Avoid N+1 queries. Bad:

// N+1: One query per user
const users = await query('SELECT id, name FROM users LIMIT 10');
for (const user of users.rows) {
  const orders = await query('SELECT id, total FROM orders WHERE user_id = $1', [user.id]);
  user.orders = orders.rows;
}

Good:

// Single query with JOIN
const result = await query(`
  SELECT u.id, u.name, o.id as order_id, o.total
  FROM users u
  LEFT JOIN orders o ON u.id = o.user_id
  LIMIT 10
`);

// Group results in application
const userMap = new Map<number, any>();
for (const row of result.rows) {
  if (!userMap.has(row.id)) {
    userMap.set(row.id, { id: row.id, name: row.name, orders: [] });
  }
  if (row.order_id) {
    userMap.get(row.id)!.orders.push({ id: row.order_id, total: row.total });
  }
}
const users = Array.from(userMap.values());

One query is always faster than many queries, even if it returns more data.

Monitoring and Load Testing

You can’t optimize what you don’t measure. Before claiming your API is fast, load test it.

Use a tool like Apache Bench, wrk, or k6. Here’s a simple k6 test:

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '5m', target: 100 },
    { duration: '2m', target: 0 },
  ],
};

export default function () {
  const userId = Math.floor(Math.random() * 1000) + 1;
  const res = http.get(`http://localhost:3000/users/${userId}`);

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 100ms': (r) => r.timings.duration < 100,
  });

  sleep(1);
}

Run this and watch your metrics: response time, error rate, and database connection count. If p99 latency spikes above 500ms or errors appear, you’ve found a bottleneck.

Common issues:

  • Connection pool exhaustion (watch pool.waitingCount in logs)
  • Slow queries (check query duration logs)
  • Memory leaks (heap size growing over time)
  • CPU saturation (Node.js is single-threaded per process; use clustering or multiple processes)

Practical Deployment Patterns

In production, run multiple Node.js processes behind a load balancer. Each process gets its own connection pool.

import cluster from 'cluster';
import os from 'os';
import express from 'express';

const app = express();
const numCPUs = os.cpus().length;

if (cluster.isPrimary) {
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died`);
    cluster.fork();
  });
} else {
  app.listen(3000, () => {
    console.log(`Worker ${process.pid} started`);
  });
}

With 4 CPUs and max pool size 20, you get 80 total connections. That’s plenty for most workloads.

Monitor everything: response times, error rates, database connections, memory, CPU. Set up alerts. When p99 latency exceeds your SLA, you want to know immediately.

Wrapping Up

High-performance REST APIs don’t happen by accident. They’re built on three pillars: proper connection pooling to eliminate connection overhead, TypeScript strictness to prevent runtime surprises, and relentless query optimization to keep database load low.

Start with connection pooling configured conservatively. Enable TypeScript strict mode. Profile your queries and add indexes where needed. Load test before you ship. Monitor in production.

These patterns scale. I’ve used them to handle 50k+ requests per second on a single database instance. The principles are the same whether you’re building a small API or an enterprise system.

What’s the right connection pool size?

Start with max: 20 and monitor pool.waitingCount in your logs. If it’s consistently above zero, increase by 5 and retest. If it’s always zero, you’re probably over-provisioned. Aim for 10-30 depending on your database’s max_connections setting and how many services connect to it.

Should I use an ORM like TypeORM or Prisma?

ORMs add abstraction and type safety, which is valuable. However, they can hide performance issues. If you use an ORM, understand its connection pooling and query generation. Test your queries with EXPLAIN ANALYZE. For high-throughput APIs, raw SQL with a query builder like Knex often gives you more control.

How do I prevent connection leaks?

Always ensure connections are returned to the pool. With the query wrapper pattern shown above, this happens automatically. If you manually acquire connections, wrap them in try-finally. Set idleTimeoutMillis to close stale connections. Monitor pool size over time; if it grows, you have a leak.

What about caching?

Caching is orthogonal to these patterns but complementary. Cache expensive queries in Redis or in-memory. However, don’t let caching hide a poorly optimized query. Optimize first, cache second. A fast database is better than a slow database with caching.

How do I handle database failover?

Connection pooling handles temporary network blips with connectionTimeoutMillis. For true failover, use read replicas and a connection pool that can route to multiple endpoints. Most managed databases (RDS, Cloud SQL) handle this for you. At the application level, implement retry logic with exponential backoff for transient errors.

Leave a Reply

Your email address will not be published. Required fields are marked *