Come ottimizzare la performance delle applicazioni Node.js

Node.js è eccellente per applicazioni I/O-bound (API REST, microservizi, real-time) grazie all’event loop e a un modello asincrono efficiente. Le performance, però, non dipendono solo dalla “velocità” del runtime: contano la progettazione delle API, l’uso corretto di CPU e memoria, la gestione della concorrenza, la qualità delle query al database, la configurazione della rete e l’osservabilità. Questo articolo raccoglie pratiche e tecniche concrete, con esempi, per migliorare latenza, throughput e stabilità in produzione.

1. Definire obiettivi e misurare prima di ottimizzare

Ogni ottimizzazione sensata parte da numeri: p95/p99 di latenza, throughput (req/s), error rate, consumo CPU/memoria e tempo di risposta delle dipendenze (DB, cache, servizi terzi). Senza baseline si rischia di “migliorare” una metrica e peggiorarne un’altra. Definisci obiettivi (SLO) e carichi realistici (dimensione payload, mix endpoint, percentuali di cache hit, pattern di traffico).

  • Profiling CPU: individua hot path e funzioni costose.
  • Heap e GC: verifica allocazioni e pressione sul garbage collector.
  • Tracing: misura dove finisce il tempo in una richiesta end-to-end.
  • Load test: riproduci carichi simili alla produzione e confronta versioni.

Strumenti utili

  • Node.js Inspector e profili V8
  • Clinic.js (Doctor/Flame/Heap)
  • 0x per flamegraph
  • OpenTelemetry per tracing, metrics e logs
  • Autocannon/k6/wrk per load test

2. Capire i colli di bottiglia tipici in Node.js

Node.js usa un singolo thread per l’event loop: se esegui lavoro CPU-bound nel thread principale, blocchi la gestione delle richieste concorrenti. Al contrario, le operazioni I/O (rete, disco, DB) sono gestite in modo asincrono e possono scalare bene finché non saturi risorse esterne. Le ottimizzazioni più comuni mirano a:

  • Ridurre lavoro sincrono e CPU nel thread principale
  • Limitare allocazioni di memoria e oggetti temporanei
  • Ridurre round-trip di rete e chiamate a dipendenze
  • Ottimizzare serializzazione/deserializzazione e dimensione dei payload
  • Gestire correttamente la concorrenza e il backpressure

3. Ottimizzare l’event loop: evitare blocchi e sincronismi inutili

Le chiamate sincrone (file system, crypto, compressione, parsing pesante) bloccano l’event loop. Preferisci API async, sposta lavoro CPU-bound su worker threads, o delega a servizi esterni se appropriato.

Usare versioni asincrone delle API

import { readFile } from "node:fs/promises";

// Bene: non blocca l'event loop
const config = JSON.parse(await readFile("./config.json", "utf8"));

// Da evitare in request path:
// import fs from "node:fs";
// const config = JSON.parse(fs.readFileSync("./config.json", "utf8"));

Identificare blocchi dell’event loop

In produzione, un indicatore semplice è il ritardo dell’event loop (event loop lag). Valori costantemente elevati suggeriscono lavoro CPU nel thread principale o troppa pressione sul GC.

import { monitorEventLoopDelay } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

setInterval(() => {
  const p99 = h.percentile(99) / 1e6; // ms
  const mean = h.mean / 1e6; // ms
  console.log({ eventLoopLagMeanMs: mean.toFixed(2), eventLoopLagP99Ms: p99.toFixed(2) });
  h.reset();
}, 5000).unref();

4. CPU-bound: worker threads, clustering e strategie di scalabilità

Se la tua applicazione fa elaborazioni pesanti (PDF, immagini, crittografia intensa, trasformazioni dati, regex costose), considera:

  • Worker Threads: parallelizzare compiti CPU-bound senza bloccare l’event loop.
  • Cluster / processi multipli: sfruttare più core CPU per servire più richieste.
  • Offloading: delegare a job queue o servizi dedicati (es. transcoding).

Esempio: worker pool minimale

Un pool riduce l’overhead di creare/distruggere worker e permette di limitare concorrenza. In scenari reali, valuta librerie mature o implementazioni più robuste (coda, timeout, cancellazione, metriche).

import { Worker } from "node:worker_threads";
import os from "node:os";

const size = Math.max(1, os.cpus().length - 1);
const workers = Array.from({ length: size }, () => new Worker(new URL("./worker.js", import.meta.url)));
let idx = 0;

function runJob(payload) {
  return new Promise((resolve, reject) => {
    const w = workers[idx++ % workers.length];
    const onMessage = (msg) => {
      w.off("error", onError);
      resolve(msg);
    };
    const onError = (err) => {
      w.off("message", onMessage);
      reject(err);
    };
    w.once("message", onMessage);
    w.once("error", onError);
    w.postMessage(payload);
  });
}

export async function expensiveEndpointHandler(req, res) {
  const result = await runJob({ input: req.body });
  res.json(result);
}

5. Performance HTTP: keep-alive, timeouts, compressione e streaming

Le API REST performanti evitano handshake ripetuti e gestiscono correttamente le connessioni. Abilita keep-alive lato client (verso servizi terzi) e definisci timeout sensati per evitare socket “zombie”.

HTTP client con keep-alive e timeout

import https from "node:https";

const agent = new https.Agent({
  keepAlive: true,
  maxSockets: 200,
  maxFreeSockets: 50,
  timeout: 30_000
});

async function fetchUpstream(url) {
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), 3_000);

  try {
    const res = await fetch(url, { agent, signal: controller.signal });
    if (!res.ok) throw new Error(`Upstream status ${res.status}`);
    return await res.json();
  } finally {
    clearTimeout(t);
  }
}

Compressione: usala con criterio

Comprimere riduce banda e spesso migliora latenza percepita, ma costa CPU. In ambienti ad alto throughput, valuta di comprimere solo risposte grandi e di lasciare che un reverse proxy gestisca compressione e caching. Per payload grandi, preferisci lo streaming invece di costruire tutto in memoria.

Streaming di risposte e backpressure

import { pipeline } from "node:stream/promises";
import { createReadStream } from "node:fs";
import zlib from "node:zlib";

export async function downloadHandler(req, res) {
  res.setHeader("Content-Type", "application/octet-stream");
  res.setHeader("Content-Encoding", "gzip");

  await pipeline(
    createReadStream("/data/bigfile.bin"),
    zlib.createGzip({ level: 6 }),
    res
  );
}

6. Payload e serializzazione: meno dati, più velocità

Trasferire e serializzare grandi JSON è costoso. Riduci campi inutili, usa paginazione, filtri e proiezioni lato DB. Considera formati più efficienti (ad esempio MessagePack) se hai controllo sui client e il guadagno giustifica complessità e compatibilità. Per JSON:

  • Evita trasformazioni multiple dello stesso oggetto
  • Usa risposte “lean” con campi minimi
  • Preferisci date/enum coerenti per ridurre parsing lato client

Validazione e parsing body

Validare input è fondamentale, ma può essere costoso se fatto male. Evita validazioni ridondanti e limita la dimensione del body. Applica limiti e rifiuta payload eccessivi.

import express from "express";

const app = express();

app.use(express.json({
  limit: "1mb",
  strict: true,
  type: ["application/json", "application/*+json"]
}));

7. Database: indici, query efficienti, pooling e riduzione round-trip

Nelle API REST, il database è spesso il vero collo di bottiglia. Prima di ottimizzare Node.js, ottimizza query, indici e access patterns.

  • Indici: assicurati che le query più frequenti usino indici appropriati.
  • Batching: raggruppa operazioni quando possibile.
  • Pooling: configura correttamente la dimensione del pool e i timeout.
  • N+1: evita query per ogni elemento; usa join/aggregazioni o prefetch.

Esempio: pool PostgreSQL con timeout e limiti

import pg from "pg";

export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 2_000
});

export async function listUsers(limit = 50) {
  const { rows } = await pool.query(
    "SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT $1",
    [limit]
  );
  return rows;
}

Regola “max” del pool in base a: core disponibili, capacità del DB, numero di istanze del servizio e traffico atteso. Un pool troppo grande aumenta contesa e può peggiorare le performance. Se usi ORM, controlla le opzioni per query “lean” e per evitare hydration di oggetti complessi quando non serve.

8. Cache: strategie, invalidazione e prevenire cache stampede

La cache riduce latenza e carico su DB/servizi esterni. Puoi usare:

  • Cache in-memory: veloce, ma per istanza; attenzione a memoria e invalidazione.
  • Cache distribuita (es. Redis): condivisa tra istanze, più robusta.
  • HTTP caching: ETag, Cache-Control per client e CDN.

Cache con TTL e “single flight” per evitare stampede

const cache = new Map(); // key -> { value, expiresAt }
const inflight = new Map(); // key -> Promise

async function getOrSet(key, ttlMs, producer) {
  const now = Date.now();
  const hit = cache.get(key);
  if (hit && hit.expiresAt > now) return hit.value;

  if (inflight.has(key)) return inflight.get(key);

  const p = (async () => {
    try {
      const value = await producer();
      cache.set(key, { value, expiresAt: now + ttlMs });
      return value;
    } finally {
      inflight.delete(key);
    }
  })();

  inflight.set(key, p);
  return p;
}

L’invalidazione è la parte difficile: preferisci chiavi ben progettate, TTL coerenti e, quando serve consistenza, usa strategie di cache-aside con eventi di invalidazione o versionamento delle chiavi.

9. Concorrenza e limiti: rate limiting, circuit breaker e bulkhead

Anche con codice efficiente, un picco di traffico può saturare il DB o un servizio terzo. Proteggi il sistema con limiti e degradazione controllata:

  • Rate limiting: limita richieste per IP/utente/chiave API.
  • Circuit breaker: se l’upstream fallisce o rallenta, interrompi temporaneamente.
  • Bulkhead: separa risorse per evitare che un endpoint “affami” gli altri.
  • Timeout e retry con backoff: retry solo quando ha senso.

Limitare la concorrenza con una coda semplice

function pLimit(concurrency) {
  let active = 0;
  const queue = [];

  const next = () => {
    if (active >= concurrency) return;
    const item = queue.shift();
    if (!item) return;
    active++;
    item()
      .finally(() => {
        active--;
        next();
      });
  };

  return (fn) => new Promise((resolve, reject) => {
    queue.push(() => Promise.resolve().then(fn).then(resolve, reject));
    next();
  });
}

const limitDb = pLimit(50);

export async function handler(req, res) {
  const data = await limitDb(() => expensiveDbCall(req.query));
  res.json(data);
}

10. Memoria e garbage collector: ridurre allocazioni e leak

In Node.js, molta latenza “strana” deriva dal GC quando l’heap cresce o quando si creano troppi oggetti temporanei. Obiettivi pratici:

  • Ridurre creazione di oggetti in hot path (map/filter in catene lunghe, spread, clone inutili)
  • Riutilizzare buffer e strutture dove ha senso
  • Evitare cache in-memory senza limiti (LRU, TTL e size cap)
  • Trovare e correggere memory leak (listener non rimossi, riferimenti globali, map mai pulite)

Impostare limiti di memoria e diagnosticare

In container, imposta limiti coerenti e monitora RSS/heapUsed. Se vedi crescita continua dell’heap, fai heap snapshot e confronti. Evita di aumentare semplicemente il limite di memoria senza indagare.

# Esempio: limite heap (valuta in base al container e al carico)
node --max-old-space-size=2048 server.js

11. Logging e osservabilità: utile, ma non deve diventare il collo di bottiglia

Log troppo verbosi o sincronizzati degradano performance e aumentano costi. Best practice:

  • Log strutturati (JSON) con livelli; evita log per request a livello “info” in sistemi ad alto traffico
  • Usa logger asincroni e batching; evita string concatenation costosa se il livello è disabilitato
  • Metriche essenziali: latenza per endpoint, error rate, tempi DB, event loop lag, heap, GC pauses
  • Tracing distribuito per capire “dove si perde tempo” tra servizi

Esempio: misurare durata richiesta con perf_hooks

import { performance } from "node:perf_hooks";

export function timingMiddleware(req, res, next) {
  const start = performance.now();
  res.on("finish", () => {
    const ms = performance.now() - start;
    console.log(JSON.stringify({
      method: req.method,
      path: req.originalUrl,
      status: res.statusCode,
      durationMs: Number(ms.toFixed(2))
    }));
  });
  next();
}

12. Express/Fastify e scelte di framework

Express è molto diffuso e semplice, ma in applicazioni ad alto throughput potresti preferire framework più orientati alle performance come Fastify, soprattutto per routing e serializzazione. In ogni caso, i guadagni maggiori spesso arrivano da query migliori, caching e riduzione I/O, più che dal cambio di framework. Se cambi, misura con benchmark realistici.

13. Configurazione del runtime e deploy: Node.js in produzione

  • Versione: usa una versione LTS aggiornata e verifica regressioni/benefici.
  • Process management: usa un supervisor (systemd, PM2, container orchestrator) e gestisci graceful shutdown.
  • Cluster: scala per core, ma considera limiti di DB e cache.
  • DNS e networking: latenza di risoluzione e timeouts possono impattare molto.

Graceful shutdown per evitare richieste troncate

import http from "node:http";

const server = http.createServer(app);

server.listen(process.env.PORT || 3000);

let shuttingDown = false;

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;

  console.log(`Received ${signal}, closing server...`);

  // Smetti di accettare nuove connessioni
  server.close(async () => {
    try {
      await closeDb();
      console.log("Shutdown complete");
      process.exit(0);
    } catch (err) {
      console.error("Shutdown error", err);
      process.exit(1);
    }
  });

  // Hard timeout per non restare appesi
  setTimeout(() => process.exit(1), 10_000).unref();
}

14. Checklist pratica per un intervento di ottimizzazione

  1. Raccogli baseline: p95/p99, throughput, CPU, heap, tempi DB/upstream.
  2. Identifica il collo di bottiglia principale (non ottimizzare “a caso”).
  3. Riduci I/O: caching, batching, riduzione round-trip, keep-alive.
  4. Ottimizza query e indici: spesso è il miglior ritorno.
  5. Elimina blocchi dell’event loop e sposta CPU-bound su worker.
  6. Riduci payload e serializzazione: meno JSON, più efficienza.
  7. Proteggi il sistema: timeouts, rate limiting, circuit breaker.
  8. Rendi osservabile: metriche e tracing per prevenire regressioni.
  9. Ripeti load test e confronta con la baseline, versione per versione.

Conclusione

Ottimizzare Node.js significa combinare buone pratiche di runtime con architettura e disciplina di misurazione. Concentrati su colli di bottiglia reali: database, rete, payload e lavoro CPU nel thread principale. Con profiling, caching mirata, query efficienti, gestione della concorrenza e osservabilità, puoi ottenere miglioramenti sostanziali e soprattutto mantenere prestazioni stabili nel tempo.

Torna su