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
- Raccogli baseline: p95/p99, throughput, CPU, heap, tempi DB/upstream.
- Identifica il collo di bottiglia principale (non ottimizzare “a caso”).
- Riduci I/O: caching, batching, riduzione round-trip, keep-alive.
- Ottimizza query e indici: spesso è il miglior ritorno.
- Elimina blocchi dell’event loop e sposta CPU-bound su worker.
- Riduci payload e serializzazione: meno JSON, più efficienza.
- Proteggi il sistema: timeouts, rate limiting, circuit breaker.
- Rendi osservabile: metriche e tracing per prevenire regressioni.
- 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.