Avete pubblicato, e il cliente vede ancora la versione vecchia. È una delle esperienze più frustranti dello sviluppo web, e nella quasi totalità dei casi la causa è la stessa — ma la soluzione che viene tentata per prima, «aggiungo ?v=2», risolve solo un pezzo del problema.

La cache è una catena#

gli anelli
Perché "ho pubblicato e non si vede" ha quasi sempre la stessa causa:la cache non è UNA, è una catena, e basta un anello che dice di no.  browser (memoria)      ← il piu' testardo: ignora anche il ricaricamento  browser (disco)        ← Cache-Control, ETag  service worker         ← se c'e', decide LUI e puo' ignorare tutto il resto  proxy aziendale        ← invisibile a voi, spesso senza rispetto per no-cache  CDN / reverse proxy    ← ha una sua politica, spesso piu' lunga della vostra  server applicativo     ← qui la modifica c'e' gia'Ogni anello va considerato. Svuotare la cache del proprio browserverifica solo il primo, e per questo l'errore "da me funziona" nasce qui.

Il «da me funziona» nasce esattamente qui: svuotando la cache del proprio browser si verifica il primo anello e si dà per scontato il resto. Ma fra voi e l'utente possono esserci un service worker installato mesi fa, un proxy aziendale con politiche proprie e una CDN con un TTL più lungo di quello che avete configurato.

La configurazione asimmetrica#

L'idea centrale è che HTML e asset hanno proprietà opposte, e vanno trattati in modo opposto.

server.jsJavaScript
// La configurazione corretta è ASIMMETRICA: due politiche opposte per// due tipi di file con proprietà opposte.// L'HTML cambia contenuto allo stesso URL → non può essere in cache lunga.// no-cache non significa "non memorizzare": significa "memorizza pure,// ma chiedimi sempre se è ancora buono". Con ETag la risposta è un 304// da poche decine di byte.app.use(express.static(PUBLIC, { setHeaders: (res, p) => {  if (p.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');}}));// Gli asset versionati NON cambiano mai contenuto allo stesso URL:// se cambia il contenuto, cambia l'URL. Quindi cache lunga e immutable.// "immutable" dice al browser di non rivalidare NEMMENO su ricaricamento.app.use('/js-build', express.static(path.join(PUBLIC, 'js-build'), {  maxAge: '365d', immutable: true}));

Il documento HTML cambia contenuto allo stesso indirizzo: /blog oggi non è /blog di domani. Non può stare in cache lunga. Gli asset versionati hanno la proprietà inversa: se il contenuto cambia, cambia l'URL. Quindi un URL, una volta scaricato, non cambierà mai — ed è la definizione esatta di immutable.

L'errore che annulla tutto#

la riga che rompe il cache busting
- Cache-Control: public, max-age=86400        # HTML in cache per un giorno+ Cache-Control: no-cache                     # rivalida sempre (ETag)# La prima riga è l'errore che rende invisibile ogni altra precauzione:# il documento con i riferimenti versionati resta in cache 24 ore, quindi# per 24 ore il browser continua a chiedere i vecchi URL — che, essendo# immutabili, ha già in cache. L'aggiornamento non arriva mai.

È il caso che rende incomprensibile il problema, perché tutto il resto sembra fatto bene: gli asset sono versionati, gli header sono lunghi, la build funziona. Ma il documento che nomina gli asset è a sua volta in cache per ventiquattro ore. Per ventiquattro ore il browser legge la pagina vecchia, che punta ai vecchi URL versionati, che essendo immutabili ha già in memoria. Non parte nemmeno una richiesta.

Query string o hash nel nome#

due strategieHTML
// Due modi di versionare, con una differenza pratica che conta.// 1. Query string: l'URL del file resta lo stesso, cambia il parametro.//    Semplice, e sufficiente per il browser. Il problema è che ALCUNI//    proxy intermedi ignorano la query string nella chiave di cache://    per loro /app.js?v=1 e /app.js?v=2 sono lo stesso oggetto.<script src="/js-build/app.js?v=1718031040"></script>// 2. Hash nel nome: l'URL è diverso, quindi è un oggetto diverso per//    chiunque, senza eccezioni. È l'approccio da preferire in produzione.<script src="/js-build/app.9f3a2c1e.js"></script>

Per il browser sono equivalenti: la query string fa parte della chiave di cache. La differenza emerge negli anelli intermedi — alcuni proxy, per configurazione o per scelta, usano solo il percorso come chiave, e per loro /app.js?v=1 e /app.js?v=2 sono lo stesso oggetto. Con l'hash nel nome il problema non esiste: sono due file diversi per chiunque.

C'è un secondo vantaggio dell'hash, meno ovvio: permette il rollback pulito. I file vecchi restano sul disco con il loro nome, quindi tornare alla versione precedente significa cambiare il manifesto — e chi aveva ancora aperta la pagina vecchia continua a trovare i suoi asset invece di ricevere un 404.

Versionare con la data di modifica#

versione.jsJavaScript
// Versionare con il tempo di modifica: nessun passaggio di build, il// numero si ricava dal file stesso a ogni avvio.function versioneAsset() {  const file = ['css-build/app.css', 'js-build/app.js'];  let max = 0;  for (const f of file) {    const m = fs.statSync(path.join(ASSET_DIR, f)).mtimeMs;    if (m > max) max = m;  }  return Math.floor(max);}// Funziona bene ed è quello che uso su questo sito. Due limiti da// conoscere prima di adottarlo://// - il tempo di modifica cambia anche quando il CONTENUTO non cambia//   (un rsync senza -a, un ripristino da backup): si costringono tutti//   i visitatori a riscaricare un file identico;// - con più server, i tempi di modifica differiscono: due utenti//   ricevono URL diversi per lo stesso file. Innocuo per la correttezza,//   ma dimezza l'efficacia della cache condivisa.//// Con l'hash del contenuto nessuno dei due si presenta: stesso contenuto,// stesso nome, su qualunque macchina.

È la soluzione che uso su questo sito, ed è ragionevole per un progetto servito da una macchina sola: non richiede alcun passaggio di build e il numero si ricava dal file. I due limiti vanno però conosciuti, perché diventano rilevanti in fretta — il tempo di modifica cambia anche quando il contenuto non è cambiato (un ripristino da backup fa riscaricare a tutti file identici), e con più server i valori differiscono, frammentando la cache condivisa.

hash-build.jsJavaScript
// L'hash del contenuto, in venti righe e senza aggiungere strumenti.const crypto = require('crypto');function scriviConHash(sorgente, cartellaUscita, nomeBase, estensione) {  const contenuto = fs.readFileSync(sorgente);  const hash = crypto.createHash('sha256')    .update(contenuto)    .digest('hex')    .slice(0, 8);                      // 8 caratteri: 4 miliardi di valori,                                       // più che sufficienti per un progetto  const nome = `${nomeBase}.${hash}${estensione}`;  fs.writeFileSync(path.join(cartellaUscita, nome), contenuto);  return nome;}// Il build scrive anche un manifesto, che il server legge una volta// all'avvio per sapere quale nome usare nei template.const manifesto = {  'app.js':  scriviConHash('build/app.js',  OUT, 'app',  '.js'),  'app.css': scriviConHash('build/app.css', OUT, 'app', '.css')};fs.writeFileSync(path.join(OUT, 'manifesto.json'), JSON.stringify(manifesto, null, 2));

Otto caratteri esadecimali sono quattro miliardi di valori possibili: la probabilità che due versioni consecutive di uno stesso file collidano è trascurabile, e il nome resta leggibile.

L'anello che decide da solo#

sw.jsJavaScript
// Il service worker è l'anello che rende inutile tutto il resto, perché// intercetta le richieste PRIMA che escano dal browser.//// ✗ Il difetto classico: mettere in cache l'HTML con "cache first".//   L'utente riceve per sempre la pagina della prima visita, con dentro//   i vecchi URL versionati. Il cache busting non entra mai in gioco.self.addEventListener('fetch', (e) => {  e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));});// ✓ Politiche diverse per tipo di risorsa, come per gli header HTTP.self.addEventListener('fetch', (e) => {  const url = new URL(e.request.url);  // Navigazioni: rete per prima, cache solo come rete di salvataggio  // quando si è offline.  if (e.request.mode === 'navigate') {    e.respondWith(fetch(e.request).catch(() => caches.match('/offline.html')));    return;  }  // Asset con hash nel nome: cache per sempre, senza pensarci.  if (/\.[0-9a-f]{8}\.(js|css|woff2)$/.test(url.pathname)) {    e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));  }});

Un service worker intercetta le richieste prima che lascino il browser: se decide di rispondere dalla propria cache, nessun header HTTP viene mai consultato. È lo strumento più potente della catena, e quello con cui è più facile rendersi irraggiungibili.

Verificare invece di provare a ricaricare#

diagnosi
# Verificare gli header invece di "provare a ricaricare".curl -sI https://esempio.it/ | grep -iE 'cache-control|etag|last-modified'cache-control: no-cacheetag: W/"3f2-19a1c8f5e20"curl -sI https://esempio.it/js-build/app.9f3a2c1e.js | grep -i cache-controlcache-control: public, max-age=31536000, immutable# La rivalidazione funziona? Si rimanda l'ETag e ci si aspetta un 304.curl -sI https://esempio.it/ -H 'If-None-Match: W/"3f2-19a1c8f5e20"' | head -1HTTP/2 304# E la CDN? La sua politica puo' essere piu' lunga della vostra.curl -sI https://esempio.it/ | grep -iE 'age|x-cache|cf-cache-status'age: 0x-cache: MISS
Trenta secondi che sostituiscono mezz'ora di ricaricamenti forzati e di «prova a svuotare la cache».
RisorsaCache-ControlPerché
Documenti HTMLno-cachestesso URL, contenuto che cambia: va rivalidato
JS/CSS con hash nel nomepublic, max-age=31536000, immutablequell'URL non cambierà mai contenuto
Font (.woff2)public, max-age=2592000stabili, ma di solito senza versione nel nome
Immagini di contenutopublic, max-age=604800sostituibili allo stesso percorso: meglio non esagerare
Risposte API pubblicheno-cache + ETagrivalidazione economica, dati sempre freschi
Risposte API con dati personaliprivate, no-storenon devono restare su disco né su proxy condivisi
/sitemap.xml, /robots.txtpublic, max-age=3600letti da macchine, cambiano di rado

La regola che riassume tutto: un URL immutabile può stare in cache per sempre, un URL mutabile va rivalidato sempre. Tutto il lavoro del cache busting consiste nello spostare quanti più file possibile nella prima categoria, lasciando nella seconda solo il documento che li nomina.