Come ottimizzare la performance del codice JavaScript

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

EsigenzaStruttura consigliataMotivo
Membership test (contiene?)SetLookup medio O(1)
Mappa chiave a valoreMapIterazione e chiavi non-stringa efficienti
Append e scansioneArrayCompatto e veloce per iterazioni lineari
Stringhe concatenate in loopArray + joinRiduce 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.
Torna su