Header Expires per file htaccess
In un progetto di migrazione infrastrutturale per una PMI ho ricevuto una segnalazione che si ripeteva a ogni rilascio: dopo ogni aggiornamento del sito, una parte degli utenti vedeva il layout rotto, con il CSS nuovo applicato a una struttura HTML vecchia o viceversa, e la cosa si risolveva "da sola" solo dopo giorni. La causa era nel file .htaccess: conteneva esattamente il blocco mod_expires che si trova in mille tutorial, quello che imposta un mese di cache per CSS e JavaScript per tipo MIME. Funzionava benissimo per la velocità, e altrettanto bene per servire agli utenti la versione vecchia degli asset per un mese dopo ogni deploy.
Quel caso è il motivo per cui vale la pena rivedere da capo come si imposta il caching del browser via .htaccess nel 2026, perché la tecnica di base è ancora valida e utile, ma la ricetta classica nasconde un difetto strutturale che non si vede finché non rilasci un aggiornamento. E la soluzione, controintuitivamente, non sta nel regolare meglio la durata della cache: sta nel cambiare il nome dei file.
TL;DR
- Header moderno: usa
Cache-Control, nonExpires(quando ci sono entrambi vince Cache-Control);mod_expireslo emette già.- Il vero fix non è la durata, è il nome del file: fai il fingerprinting degli asset (
app.4f3a9c1.js) e dai loroCache-Control: max-age=31536000, immutable.- HTML: mai cache lunga (
max-age=0, sempre fresco); asset fingerprinted: un anno +immutable.- Risorse non fingerprintabili (es. immagini caricate): revalidation con
ETag/Last-Modified(risposta 304).- Verifica:
curl -Isugli asset e l'audit "efficient cache policy" di Lighthouse/PageSpeed.
Cache-Control ha superato Expires: cosa cambia davvero
Partiamo dal nome dell'articolo, perché è già parte del problema. L'header Expires indica al browser una data assoluta oltre la quale una risorsa è considerata scaduta. È il meccanismo storico, ed è quello che il modulo mod_expires di Apache configura. Il punto è che oggi lo standard di riferimento per il caching HTTP è l'header Cache-Control, che esprime la stessa intenzione in modo relativo e molto più espressivo: max-age=2592000 dice "questa risorsa è valida per 2.592.000 secondi (un mese) dal momento in cui l'hai ricevuta", senza dipendere dall'orologio del client né da una data fissa. Quando entrambi gli header sono presenti, i browser danno la precedenza a Cache-Control, quindi Expires è di fatto un fallback per client antichi. La buona notizia è che mod_expires, quando lo configuri, emette automaticamente anche l'header Cache-Control: max-age corrispondente, come spiega la documentazione ufficiale di mod_expires. Quindi non stai sbagliando a usarlo, ma è bene sapere che lo strumento moderno che vuoi davvero controllare è Cache-Control, e che ha direttive che Expires non può esprimere.
Le direttive che contano, e che il blocco del 2017 non usava perché allora non erano diffuse, sono tre. immutable dice al browser che la risorsa non cambierà mai per tutta la durata della sua validità, così che non tenti nemmeno una richiesta di revalidation quando l'utente ricarica la pagina: un guadagno reale di richieste risparmiate. stale-while-revalidate permette al browser di servire una risorsa scaduta mentre ne controlla in background una versione aggiornata, eliminando l'attesa percepita. E public o private distinguono ciò che può essere messo in cache da proxy e CDN condivisi da ciò che è specifico del singolo utente. Tutte e tre sono documentate nella pagina di riferimento di MDN su Cache-Control, ed è lì che vive oggi la logica del caching, non nell'header Expires.
Il vero problema non è la durata, è il nome del file
Ora il cuore della questione, quello che trasformava una buona configurazione in un generatore di disservizi. La regola "CSS e JS scadono dopo un mese" contiene una contraddizione insanabile se i file hanno nomi fissi come style.css o app.js. Da un lato vuoi una cache lunga, perché quegli asset cambiano di rado e ricaricarli a ogni visita è uno spreco. Dall'altro, quando finalmente li aggiorni con un deploy, vuoi che il browser scarichi subito la versione nuova, non fra un mese. Con un nome di file fisso e una cache di un mese, queste due esigenze sono in conflitto diretto, e il classico mod_expires per tipo MIME risolve il conflitto nel modo sbagliato: privilegia la durata e sacrifica la freschezza, lasciando gli utenti con asset obsoleti dopo ogni rilascio.
La soluzione è il fingerprinting (o cache busting): far sì che il nome del file cambi quando il suo contenuto cambia. Invece di servire app.js, si serve app.4f3a9c1.js, dove quella stringa è un hash del contenuto. Quando modifichi il file, l'hash cambia, quindi cambia il nome, quindi per il browser è una risorsa nuova che non ha mai visto e che scarica immediatamente, mentre la vecchia resta in cache inutilizzata e prima o poi viene scartata. Questo ribalta completamente la logica: a un asset con nome fingerprinted puoi dare la cache più aggressiva possibile, un anno e immutable, proprio perché quel preciso file non cambierà mai (se cambiasse, avrebbe un altro nome). Ed è esattamente ciò che fanno gli asset bundler moderni, da Vite a Webpack agli strumenti integrati nei framework: generano nomi con hash di default. La domanda giusta, quindi, non è "quanto faccio durare la cache dei CSS", ma "i miei asset hanno un nome fingerprinted?". Se sì, la durata diventa banale; se no, nessuna durata è quella giusta.
Se gestisci un sito dove i deploy lasciano gli utenti con risorse vecchie, o dove al contrario la cache è troppo timida e ogni pagina ricarica tutto, è il tipo di problema su cui intervengo regolarmente: nel mio profilo professionale trovi l'esperienza concreta su tuning di server web, performance e pipeline di asset per applicativi in produzione.
Una configurazione .htaccess corretta per il 2026
Mettendo insieme i pezzi, ecco come riscrivo il blocco di caching tenendo conto del fingerprinting. La logica è a due velocità: l'HTML non si mette mai in cache a lungo (è il punto di ingresso che deve sempre riflettere l'ultima versione e referenziare i nomi fingerprinted aggiornati), mentre gli asset statici fingerprinted ricevono la cache massima con immutable.
<IfModule mod_expires.c>
ExpiresActive On
# HTML: mai cache lunga, e' il punto d'ingresso che deve restare fresco
ExpiresByType text/html "access plus 0 seconds"
# Asset statici fingerprinted: cache massima, immutabile
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
# Immagini di contenuto (non fingerprinted): durata media
ExpiresByType image/webp "access plus 1 month"
ExpiresByType image/avif "access plus 1 month"
</IfModule>
<IfModule mod_headers.c>
# Aggiunge immutable agli asset con hash nel nome (es. app.4f3a9c1.js)
<FilesMatch "\.[0-9a-f]{8,}\.(css|js|woff2|svg)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>Noterai alcune scelte rispetto al blocco del 2017. I tipi MIME obsoleti sono spariti: application/x-font-woff ha lasciato il posto a font/woff2, che è il formato di font del web moderno; text/cache-manifest (l'appcache) è una tecnologia deprecata da anni e non ha più senso configurarla; i formati immagine includono ora webp e avif, che sono quelli che si servono oggi. E soprattutto, la cache lunga con immutable è applicata in modo mirato ai soli file che hanno un hash nel nome, tramite FilesMatch, non a tutti i CSS e JS indistintamente. Questo è il dettaglio che rende la configurazione insieme aggressiva sulla performance e sicura sui rilasci: solo ciò che è davvero immutabile riceve il trattamento da immutabile.
Resta una categoria di risorse che non si può fingerprintare: i contenuti dinamici o caricati dagli utenti, come le immagini di un catalogo che possono cambiare mantenendo lo stesso URL. Per questi non si usa la cache immutabile, ma la revalidation: gli header ETag (un'impronta del contenuto) e Last-Modified (la data di ultima modifica) permettono al browser di chiedere al server "è cambiato rispetto a quello che ho?" inviando un valore di confronto. Se il contenuto è invariato, il server risponde con uno stato 304 Not Modified e un corpo vuoto, e il browser riusa la sua copia: si paga il costo di un round-trip di rete, ma non quello di ritrasferire la risorsa. È il compromesso giusto quando non puoi permetterti la cache lunga perché il contenuto può cambiare, ma vuoi comunque evitare di riscaricare ciò che non è cambiato. La regola pratica è netta: cache lunga e immutabile per gli asset fingerprinted, revalidation con ETag per i contenuti dinamici a URL stabile, nessuna cache per l'HTML.
Come verificare e misurare che il caching funzioni
Una configurazione di caching va sempre verificata, perché il modo più silenzioso in cui fallisce è "sembra impostata ma non ha effetto". Il primo controllo è diretto: si ispezionano gli header di risposta di una risorsa. Dagli strumenti per sviluppatori del browser, nella scheda di rete, si guarda l'header Cache-Control di un CSS o di un'immagine e si verifica che sia quello atteso; ricaricando la pagina, una risorsa cachata correttamente compare come servita dalla cache (memory o disk cache) e non come una nuova richiesta con stato 200. Lo stesso si fa da riga di comando con curl -I sull'URL della risorsa, che mostra gli header senza scaricare il corpo: è il modo più rapido per confermare che il server stia davvero emettendo Cache-Control: public, max-age=31536000, immutable su un asset fingerprinted e max-age=0 sull'HTML.
C'è poi un controllo che conta come misura d'impatto: gli strumenti di analisi delle performance come Lighthouse e PageSpeed Insights hanno un audit specifico, "serve static assets with an efficient cache policy", che segnala esattamente le risorse con una cache troppo corta o assente. Vederlo passare al verde è la conferma che la configurazione è efficace agli occhi degli stessi sistemi che Google usa per valutare la velocità del sito, quella velocità che, come è noto da tempo, è un fattore di ranking reale.
Vale infine la pena sapere che il caching è solo metà della delivery efficiente degli asset: l'altra metà è la compressione. Servire CSS e JavaScript compressi con Brotli (o in subordine gzip) riduce drasticamente il peso trasferito, e si configura nello stesso .htaccess con mod_deflate o mod_brotli. Caching e compressione lavorano insieme, la prima evita di ritrasferire ciò che il client ha già, la seconda riduce il costo del primo trasferimento, e una delivery curata le imposta entrambe. C'è anche un dettaglio da non dimenticare quando si comprime e si serve contenuto negoziato: l'header Vary deve riflettere le dimensioni su cui la risposta varia (tipicamente Accept-Encoding), altrimenti una cache condivisa potrebbe servire una versione compressa a un client che non la supporta.
Apache, Nginx, e il livello che spesso conta di più
Due note di contesto, perché il .htaccess non è l'intera storia. La prima: il file .htaccess esiste solo nel mondo Apache, e molti progetti sono migrati o nascono su Nginx, dove non esiste un file equivalente per-directory e la stessa logica di caching si esprime nella configurazione del server con direttive expires e add_header Cache-Control dentro i blocchi location. Il principio resta identico (HTML fresco, asset fingerprinted immutabili), cambia solo la sintassi; se il tuo stack è su Nginx, l'impostazione va fatta lì, ed è uno dei tasselli che curo quando configuro un server da zero, come nel percorso che descrivo nell'articolo su Let's Encrypt e Nginx con automazione multi-dominio.
La seconda, ed è quella che oggi pesa di più: davanti al server applicativo c'è quasi sempre un livello di edge cache, una CDN. E la CDN obbedisce agli stessi header Cache-Control che imposti per il browser, quindi una configurazione fatta bene non accelera solo il singolo visitatore, ma istruisce la CDN su cosa può servire dalla sua cache distribuita senza disturbare il tuo server, riducendo carico e latenza per tutti. Questo intreccio fra caching del browser, caching della CDN e caching applicativo è un sistema a più livelli che va progettato in modo coerente, perché un header sbagliato a un livello vanifica il lavoro degli altri; l'ho affrontato in dettaglio dal lato applicativo nell'articolo sul caching multilivello in Laravel per siti ad alto traffico.
Resta valido, infine, un avvertimento pratico che vale sempre la pena ribadire: il file .htaccess è potente e fragile insieme. Un errore di battitura in una direttiva, un modulo non attivo, una riga fuori posto, e Apache risponde con un errore 500 che mette offline l'intero sito. Prima di toccarlo si fa sempre un backup, si verifica con gli strumenti per sviluppatori del browser che gli header di risposta delle risorse siano effettivamente quelli attesi, e si tiene a mente che su CMS come WordPress un plugin o un altro frammento di configurazione può sovrascrivere il comportamento che hai impostato.
Impostare il caching del browser via .htaccess, nel 2026, non è più una questione di copiare un blocco ExpiresByType e scegliere una durata. È capire che Cache-Control con immutable ha superato Expires, che il vero abilitatore di una cache aggressiva è il fingerprinting degli asset e non la durata in sé, e che lo stesso header governa browser e CDN insieme. La ricetta da un mese per tipo MIME che gira da anni ti dà velocità al prezzo di servire versioni vecchie dopo ogni rilascio, esattamente il disservizio da cui sono partito. Se vuoi una configurazione di caching che sia veloce senza tradirti al primo deploy, contattami per un confronto diretto: di solito basta correggere l'impostazione una volta, agganciandola al modo in cui costruisci gli asset, perché smetta di essere un problema a ogni aggiornamento.