L'obiettivo è ridurre tempo di caricamento, lavoro sulla main thread, memoria e latenza delle interazioni. Le tecniche sotto sono pensate sia per browser sia per Node.js, con esempi pratici.
1) Misurare prima di ottimizzare
La performance si ottimizza in modo efficace quando si sa cosa è lento e perché. In browser, i colli di bottiglia più comuni sono: parsing e compilazione di JavaScript, lavoro sincrono sulla main thread, rendering e layout eccessivi, I/O di rete e garbage collection. In Node.js spesso incidono: I/O bloccante, serializzazione, allocazioni inutili e loop CPU-bound.
1.1 Strumenti rapidi (browser)
- Performance panel (Chrome, Edge, Firefox): timeline, long tasks, layout, paint, scripting.
- Coverage: codice JavaScript non usato da eliminare o rimandare.
- Network: dimensione, compressione, caching, priorità.
- Lighthouse: audit, suggerimenti, metriche (LCP, INP, CLS).
1.2 Misurare in codice
Usa misure mirate attorno alle funzioni critiche, evitando di stimare a occhio. In browser puoi usare performance.now() e PerformanceObserver.
const t0 = performance.now();
doWork();
const t1 = performance.now();
console.log(`doWork: ${(t1 - t0).toFixed(2)} ms`);
// Long Tasks (indicazione di blocchi > 50 ms)
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("Long task:", entry.duration, "ms");
}
});
obs.observe({ entryTypes: ["longtask"] });
2) Ridurre JavaScript caricato e lavoro iniziale
Spesso il guadagno maggiore arriva riducendo byte trasferiti e lavoro di parsing e compilazione. Ogni kilobyte in meno significa meno rete, meno decompressione, meno parse e meno lavoro del motore.
2.1 Code splitting e caricamento condizionale
Carica solo ciò che serve quando serve. Con bundler moderni (Vite, Webpack, Rollup) le dynamic import creano chunk separati.
// Esempio: caricare un editor pesante solo quando l'utente lo apre
button.addEventListener("click", async () => {
const { mountEditor } = await import("./editor.js");
mountEditor(document.getElementById("target"));
});
2.2 Elimina dipendenze e dead code
- Preferisci API native (fetch, URL, Intl) quando equivalenti.
- Verifica la dimensione delle librerie e la qualità del tree-shaking.
- Evita import a pacchetto intero se puoi importare moduli specifici.
// Meno efficiente: importa tutto
import _ from "lodash";
// Meglio: importa solo ciò che usi
import debounce from "lodash/debounce";
2.3 Defer, async e ordine degli script
In browser, uno script senza attributi blocca il parsing HTML. Se lo script non è necessario per il render iniziale, usa defer (mantiene l'ordine) o async (non mantiene l'ordine).
<script src="app.js" defer></script>
<script src="analytics.js" async></script>
3) Evitare blocchi sulla main thread
L’esperienza utente peggiora quando la main thread è occupata: click che non rispondono, input lag e scroll scattoso. Obiettivo: frammentare il lavoro e spostare ciò che è possibile fuori dalla main thread.
3.1 Spezzare il lavoro in chunk
Se devi processare migliaia di elementi, evita un ciclo unico sincrono. Usa una strategia cooperativa con requestIdleCallback (quando disponibile) o con micro-chunk via setTimeout o MessageChannel.
function processInChunks(items, fn, chunkSize = 200) {
let i = 0;
function run() {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) fn(items[i]);
if (i < items.length) {
// lascia respiro al browser per input e render
setTimeout(run, 0);
}
}
run();
}
3.2 Web Worker per lavoro CPU-bound
Se l'operazione è pesante (parsing, compressione, criptografia, calcoli), un Worker evita di bloccare la UI. Trasferisci dati con Transferable (ArrayBuffer) per ridurre copie.
// main.js
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
worker.onmessage = (e) => console.log("Risultato:", e.data);
const buffer = new ArrayBuffer(10_000_000);
worker.postMessage(buffer, [buffer]); // trasferisce la proprietà del buffer
// worker.js
self.onmessage = (e) => {
const buffer = e.data;
// ... calcolo pesante ...
self.postMessage({ ok: true, bytes: buffer.byteLength });
};
3.3 Debounce e throttle sugli eventi frequenti
Eventi come scroll, resize e input possono scatenare decine o centinaia di callback al secondo. Debounce e throttle riducono il lavoro.
function throttle(fn, wait) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn(...args);
}
};
}
window.addEventListener("scroll", throttle(() => {
// lavoro leggero e misurato
}, 100));
4) Ottimizzare DOM e rendering
Le operazioni sul DOM possono essere costose soprattutto se causano layout thrashing (misure e scritture alternate) o se aggiornano molti nodi uno per uno.
4.1 Batch degli aggiornamenti
Accorpa letture e scritture: prima leggi tutte le misure, poi fai tutte le modifiche. Evita pattern leggi-scrivi-leggi-scrivi nello stesso frame.
// Pattern migliore: tutte le letture, poi tutte le scritture
const width = el.offsetWidth;
const height = el.offsetHeight;
requestAnimationFrame(() => {
el.style.width = (width + 10) + "px";
el.style.height = (height + 10) + "px";
});
4.2 Usare DocumentFragment e inserimenti singoli
Creare e appendere migliaia di nodi uno alla volta può essere lento. Costruisci in memoria e poi inserisci una sola volta.
const frag = document.createDocumentFragment();
for (const item of data) {
const li = document.createElement("li");
li.textContent = item.label;
frag.appendChild(li);
}
list.appendChild(frag);
4.3 Virtualizzazione di liste lunghe
Se rendi 10.000 righe, il problema non è solo JavaScript: layout e paint diventano enormi. La virtualizzazione rende nel DOM solo ciò che è visibile (più un margine). In molti casi una libreria dedicata è la scelta più solida, ma il concetto chiave è ridurre nodi nel DOM.
5) Gestione efficiente della memoria
Allocazioni inutili e oggetti a vita lunga aumentano la pressione sul garbage collector e possono causare micro-pause. Le ottimizzazioni più utili sono spesso semplici: meno oggetti, meno copie, meno strutture inutili.
5.1 Evitare copie superflue e trasformazioni ripetute
// Evita: creare nuovi array ad ogni passo se non serve
// const out = arr.map(...).filter(...).map(...);
// Meglio: un solo passaggio quando conta
const out = [];
for (const x of arr) {
if (!predicate(x)) continue;
out.push(transform(x));
}
5.2 Attenzione a cache e leak
Le cache sono utili, ma se non hanno limiti crescono senza controllo. Preferisci una cache LRU o imposta un tetto. Inoltre, in browser, event listener non rimossi e riferimenti da strutture globali impediscono la liberazione degli oggetti.
// Esempio semplice di cache con limite
class SimpleCache {
constructor(limit = 500) {
this.limit = limit;
this.map = new Map();
}
get(k) { return this.map.get(k); }
set(k, v) {
if (this.map.size >= this.limit) {
// rimuove il primo inserito (non LRU, ma limitato)
const firstKey = this.map.keys().next().value;
this.map.delete(firstKey);
}
this.map.set(k, v);
}
}
6) Ottimizzare algoritmi e strutture dati
Prima di micro-ottimizzare, controlla la complessità. Passare da O(n²) a O(n log n) o O(n) è spesso un salto enorme rispetto a qualunque trucco di sintassi.
6.1 Scegliere la struttura giusta
| Esigenza | Struttura consigliata | Motivo |
|---|---|---|
| Membership test (contiene?) | Set | Lookup medio O(1) |
| Mappa chiave a valore | Map | Iterazione e chiavi non-stringa efficienti |
| Append e scansione | Array | Compatto e veloce per iterazioni lineari |
| Stringhe concatenate in loop | Array + join | Riduce concatenazioni ripetute |
// Esempio: membership test più veloce con Set
const allowed = new Set(["it", "en", "fr"]);
if (allowed.has(lang)) {
// ...
}
7) Ottimizzare rete e caching
Molte app sono lente per colpa della rete, non della CPU. Riduci round-trip, sfrutta cache, comprimi asset e limita richieste duplicate.
7.1 Deduplicare richieste e memoization async
const inflight = new Map();
async function fetchOnce(url, opts) {
const key = url + "::" + JSON.stringify(opts ?? {});
if (inflight.has(key)) return inflight.get(key);
const p = fetch(url, opts)
.then((r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
7.2 Service Worker (quando appropriato)
Un Service Worker può abilitare caching avanzato e offline. È potente ma va progettato con attenzione (invalidazione cache, versioning, fallback). Se la tua app ha molti asset statici e pattern ripetuti, il guadagno può essere significativo.
8) Node.js: performance del server
In Node.js, la regola d’oro è evitare lavoro bloccante nel thread principale. Per CPU-bound usa Worker Threads o processi separati; per I/O usa le API asincrone.
8.1 Evitare API sincrone in produzione
import { readFile } from "node:fs/promises";
const config = JSON.parse(await readFile("./config.json", "utf8"));
8.2 Profilazione con --prof
node --prof server.js
# genera un log V8 da analizzare per capire hot path e colli di bottiglia
9) Micro-ottimizzazioni: quando servono davvero
Micro-ottimizzazioni (per esempio scegliere tra for e forEach, evitare spread, ecc.) hanno senso solo dopo aver risolto i problemi grossi. Inoltre possono peggiorare leggibilità e manutenzione. Regola pratica:
- Prima: riduci codice caricato, evita blocchi, migliora algoritmo, riduci DOM.
- Poi: ottimizza i punti caldi misurati con profiler e benchmark.
9.1 Benchmark coerenti
Esegui benchmark con warm-up (JIT), più iterazioni e input realistici. Evita di misurare console.log o operazioni I/O insieme al lavoro CPU.
function bench(name, fn, iters = 50_000) {
// warm-up
for (let i = 0; i < 2_000; i++) fn();
const t0 = performance.now();
for (let i = 0; i < iters; i++) fn();
const t1 = performance.now();
console.log(`${name}: ${((t1 - t0) / iters).toFixed(6)} ms/op`);
}
10) Checklist finale
- Riduci JavaScript iniziale: code splitting, eliminazione dipendenze, defer e async.
- Misura: Performance panel, Coverage, metriche reali (LCP, INP, CLS).
- Evita long task: chunking, debounce e throttle, Web Worker.
- DOM: batch di letture e scritture, DocumentFragment, virtualizzazione liste.
- Memoria: meno allocazioni, cache con limiti, rimozione listener e riferimenti globali.
- Algoritmi: migliora complessità prima delle micro-ottimizzazioni.
- Rete: deduplica richieste, caching, compressione, Service Worker quando utile.
- Node.js: evita sync, profila hot path, sposta CPU-bound fuori dal main thread.