Backend·December 11, 2025·3 min read

Building High-Performance Backend APIs with Node.js

Concrete techniques for making Node.js APIs fast and predictable under load — connection pooling, streaming, worker threads, and the profiling habits that catch regressions before production does.

Advanced
Node.jsTypeScriptREST APIRedis
NT

NavikaTech

Updated February 2, 2026

Reading Progress
0%

Node.js gets criticized for performance more often than the criticism is deserved. In practice, the overwhelming majority of slow Node APIs aren't slow because of the runtime — they're slow because of blocking the event loop, misconfigured connection pools, or unbounded memory growth. This article covers the specific techniques that separate a Node API that falls over at 200 requests per second from one that comfortably handles 5,000.

Respect the event loop

Node runs your JavaScript on a single thread. Any synchronous, CPU-heavy operation — parsing a huge JSON payload, running a regex against a large string, hashing a password with a high cost factor — blocks every other in-flight request until it finishes.

bad-handler.tstypescript
// BAD: blocks the event loop for every concurrent request
app.post("/reports", async (req, res) => {
  const data = generateHugeReportSync(req.body); // CPU-bound, synchronous
  res.json(data);
});
worker-thread.tstypescript
// GOOD: offload CPU-bound work to a worker thread pool
import { Worker } from "node:worker_threads";
import { fileURLToPath } from "node:url";

function runReportWorker(payload: unknown): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(
      fileURLToPath(new URL("./report-worker.js", import.meta.url)),
      { workerData: payload }
    );
    worker.once("message", resolve);
    worker.once("error", reject);
  });
}

app.post("/reports", async (req, res) => {
  const data = await runReportWorker(req.body);
  res.json(data);
});

Connection pooling — the most common real bottleneck

A surprising share of production Node latency incidents trace back to database connection pool exhaustion, not application code. Every request holding a connection longer than necessary — an unclosed transaction, a slow query, a missing timeout — reduces the effective pool size for everyone else.

SettingToo lowToo highReasonable starting point
Pool max sizeRequests queue and time out under loadOverwhelms the database's own connection limit10–20 per app instance, tuned against DB max_connections
Idle timeoutConnections thrash open/close constantlyPool holds idle connections the DB could reclaim30s
Statement timeoutN/ARunaway queries hold connections indefinitely5–10s for OLTP workloads

Stream large responses instead of buffering them

Building a large JSON array in memory before sending it delays time-to-first-byte and spikes memory per request. Streaming sends bytes as they're ready, which matters a lot for exports, reports, and large list endpoints.

stream-export.tstypescript
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";

app.get("/export/orders", async (req, res) => {
  res.setHeader("Content-Type", "application/x-ndjson");

  const cursor = db.order.findManyCursor({ where: { status: "completed" } });

  const toNdjson = new Transform({
    objectMode: true,
    transform(order, _enc, callback) {
      callback(null, JSON.stringify(order) + "\n");
    },
  });

  await pipeline(cursor, toNdjson, res);
});

Profile before you optimize

  • node --prof and node --prof-process for CPU sampling built into the runtime, no dependencies required.
  • clinic.js Doctor for a high-level 'what's wrong' diagnosis across CPU, event loop delay, and memory.
  • autocannon for load generation to reproduce production-like concurrency locally.
  • APM tooling (Datadog, New Relic) for production-only issues that don't reproduce locally.
Every performance fix we've shipped that mattered was preceded by a profile, not a hunch. — Arjun Mehta, Principal Software Engineer, NavikaTech

Conclusion

Node.js is fast enough for the overwhelming majority of backend workloads when the event loop stays unblocked, connection pools are sized deliberately, and large payloads are streamed rather than buffered. The runtime rarely deserves the blame it gets — most performance problems live in application-level decisions that are entirely fixable.

Key Takeaways

  • Node's event loop is single-threaded — CPU-bound work belongs in worker threads, not the request handler.
  • Connection pooling misconfiguration is the most common cause of Node API latency spikes under load, not the language itself.
  • Streaming responses for large payloads reduces both memory pressure and time-to-first-byte.
  • Profile before optimizing — Node's built-in profiler and clinic.js catch real bottlenecks that intuition usually misses.

Recommended Resources

Share this article
NT
NavikaTech·Engineering Team

NavikaTech

NavikaTech shares practical engineering notes on web development, app development, custom software, automation, AI, and long-term software maintenance.

Related Articles

Stay Updated With Modern Software Engineering

One email, occasionally — practical engineering writing from the NavikaTech team, no noise.