
⚡ Come caricare JavaScript esterno senza bloccare la pagina
Quando il browser incontra un tag <script> senza attributi, molla tutto. Il rendering della pagina si blocca completamente finché lo script non viene caricato ed eseguito. Su una connessione 4G lenta, sono 2-3 secondi di schermo bianco.
A quel punto l'utente è già passato a un concorrente. I Core Web Vitals registrano un LCP fallito, Google abbassa la pagina nei risultati di ricerca e perdi traffico e conversioni. Nel frattempo, il problema si risolve con tre righe se sai dove guardare.
Ecco un metodo funzionante per caricare JavaScript esterno senza bloccare il rendering. Dall'approccio classico a due file ai moderni async/defer e all'import() dinamico. Con codice testato che puoi copiare e incollare.
💡 Panoramica rapida:
- Capire il problema: come un
<script>normale blocca il parsing HTML e uccide la velocità di caricamento - Padroneggiare l'approccio classico: un loader minuscolo (≤300 byte) carica dinamicamente il file JS principale
- Imparare gli attributi nativi
asyncedefer: quando e quale usare - Esplorare l'
import()dinamico per caricare moduli su richiesta - Scegliere una strategia per il tuo progetto con una tabella comparativa
Perché JavaScript blocca il rendering
Quando il parser HTML arriva a <script src="app.js">, fa esattamente tre cose: interrompe il parsing del documento, scarica il file, lo esegue. Solo dopo riprende a costruire il DOM.
Il motivo è architetturale. Uno script può contenere document.write(), che modifica l'HTML al volo. Il browser non sa in anticipo se c'è una chiamata del genere, quindi va sul sicuro e aspetta il caricamento e l'esecuzione completi. Risultato: anche uno script leggero da 5 KB aggiunge centinaia di millisecondi al First Contentful Paint solo per il round-trip di rete.
Il problema non è nuovo. Già nel 2009 Nicholas Zakas descrisse una tecnica per il caricamento dinamico non bloccante di JavaScript, e funziona ancora oggi, con gli adattamenti per le API moderne. Con l'arrivo di async, defer e dei moduli ES, gli sviluppatori hanno ora un toolkit completo. Esaminiamoli uno per uno.
Approccio classico: due file e caricamento dinamico
L'idea è semplice. Invece di mettere tutto il JS in un unico file e collegarlo alla pagina con <script src="...">, dividi il codice in due parti:
- Un loader minuscolo (200-300 byte dopo compressione)
- Il file principale con la logica applicativa
Il loader viene inserito inline in fondo alla pagina, subito prima di </body>. Crea un <script> in modo programmatico e lo aggiunge al DOM: un tag del genere non blocca più il parsing perché compare fuori dal flusso principale del documento. Appena il file principale si carica, l'inizializzazione viene eseguita.
Versione moderna della funzione in JS puro senza retrocompatibilità con IE:
1 function loadScript(url) { 2 return new Promise((resolve, reject) => { 3 const script = document.createElement('script'); 4 script.src = url; 5 script.onload = resolve; 6 script.onerror = reject; 7 document.head.appendChild(script); 8 }); 9 }
Nove righe. Niente controlli readyState, niente rami per vecchie versioni di IE, niente callback a piramide. Solo una funzione che restituisce una Promise, comoda da combinare con async/await.
L'uso nella pagina si presenta così (codice in fondo, prima del </body> di chiusura):
1 <script> 2 function loadScript(url) { 3 return new Promise((resolve, reject) => { 4 const script = document.createElement('script'); 5 script.src = url; 6 script.onload = resolve; 7 script.onerror = reject; 8 document.head.appendChild(script); 9 }); 10 } 11 12 loadScript('/js/app.js').then(() => { 13 // Initialize after main file loads 14 App.init(); 15 }); 16 </script>
Il primo script (inline) è il loader. Viene parsato ed eseguito all'istante perché pesa meno di 300 byte. Il secondo script (app.js) si carica in modo asincrono e non interferisce con il rendering.
E se hai più di due file? Uniscili in fase di build. I bundler moderni come Vite e Webpack lo fanno in automatico: tree-shaking, code splitting, minificazione in un unico passaggio. Gestire a mano l'ordine di caricamento di una dozzina di file è la strada per race condition ed errori.
Async e defer: sblocco nativo
HTML5 ci ha dato due attributi che risolvono il problema senza una sola riga di JavaScript:
1 <script async src="analytics.js"></script> 2 <script defer src="app.js"></script>
Entrambi caricano il file in parallelo al parsing HTML. La differenza sta nel momento dell'esecuzione:
Attributo | Caricamento | Esecuzione | Ordine |
|---|---|---|---|
| In parallelo al parsing | Subito dopo il caricamento | Non garantito |
| In parallelo al parsing | Dopo il parsing HTML completo | Garantito (come nel documento) |
Regola pratica:
asyncper script indipendenti: analytics, ads, contatori. Non hanno bisogno del DOM, non si preoccupano dell'ordine.deferper l'applicazione principale: manipolazione del DOM, inizializzazione dell'interfaccia. Lo script aspetta che la pagina sia pronta e viene eseguito nella sequenza corretta.
In pratica, la combinazione è semplice: metti defer su tutti gli script nell'<head>, e si comportano come se fossero in fondo alla pagina ma si caricano prima. Nessuna magia, solo lo scheduler del browser.
E sì, puoi combinarlo con il caricamento dinamico. Per esempio, carica il core applicativo via <script defer>, e aggancia i widget pesanti dinamicamente con loadScript() solo quando servono davvero.
Import() dinamico: moduli su richiesta
ES2020 ha introdotto l'import() dinamico, un modo nativo per caricare un modulo in modo asincrono, senza bundler e senza funzioni extra:
1 // Loads only when user clicked 2 button.addEventListener('click', async () => { 3 const { heavyChart } = await import('./chart-component.js'); 4 heavyChart.render(); 5 });
La chiamata import() restituisce una Promise. Il modulo si carica in background, il parsing non viene bloccato, la pagina resta reattiva. Il codice dentro il modulo viene eseguito in strict mode e nel proprio scope, i conflitti di nomi sono eliminati.
Questo è lo strumento ideale per il code splitting senza bundler. I componenti pesanti (grafici, editor, mappe) vengono spostati in file separati e caricati alla prima interazione. Un utente che non apre mai un grafico non lo paga in termini di traffico e tempo di caricamento.
Confronto tra gli approcci
Ogni metodo ha la sua nicchia. Per evitare di tirare a indovinare, abbiamo raccolto le caratteristiche in una tabella:
Approccio | Blocca il rendering | Richiede JS | Ordine di esecuzione | Per quali script |
|---|---|---|---|---|
| Sì | No | Garantito | Da non usare se non necessario |
| No | Sì | Via catena | Caricamento condizionale, dipendenze pesanti |
| No | No | Non garantito | Analytics, ads, contatori |
| No | No | Garantito | Applicazione principale, manipolazione DOM |
| No | Sì (modulo ES) | Via | Code splitting, componenti su richiesta |
Concetto chiave: non fissarti su un unico metodo. Un setup di produzione tipico ne usa due o tre contemporaneamente: defer per il core, async per la metrica, import() dinamico per i componenti pesanti.
Breve video dimostrativo sul tema, analisi di async e defer con visualizzazione della timeline di caricamento:
⁉️🤔 Domande frequenti
In pratica, che differenza c'è tra async e defer?
Entrambi non bloccano il parsing durante il caricamento. Ma
asyncesegue lo script subito dopo il caricamento del file, anche se l'HTML non è ancora stato parsato del tutto, e senza garanzia di ordine.deferaspetta sempre il DOM completamente pronto e preserva la sequenza degli script come nell'HTML. Per il codice applicativo principale, usadefer, per contatori isolati, usaasync.
Si può combinare il caricamento dinamico con defer?
Sì, è uno scenario comune. Il core applicativo si carica con
defernell'<head>, inizializza l'interfaccia. I moduli pesanti o usati di rado vengono caricati vialoadScript()dinamico oimport()all'interazione dell'utente. In questo modo ottieni sia un avvio veloce sia il caricamento differito del codice secondario.
Cosa uso per un sito WordPress?
WordPress aggiunge automaticamente
deferoasynctramitewp_enqueue_script()se passi l'argomento appropriato nel quinto parametro:wp_enqueue_script('my-script', $url, [], null, ['strategy' => 'defer']). Per script di terze parti (Google Analytics, ads), l'approccio più semplice è l'attributoasync. I blocchi interattivi complessi (calcolatori, filtri) vanno spostati inimport()dinamico dentro un modulo personalizzato.
Funziona con script di terze parti come Google Analytics?
Sì. Il tag GA4
gtag.jssi carica conasyncdi default, quindi non blocca la pagina. Per altri servizi di terze parti, controlla la documentazione: se lo script non richiede un DOM pronto e non dipende dall'ordine di caricamento, puoi usare tranquillamenteasync. Se ha bisogno del DOM, usadefero il caricamento dinamico con una callback.
Come verifico che uno script non blocchi davvero la pagina?
Apri Chrome DevTools → Performance → Registra → ricarica la pagina. Sulla timeline, cerca i blocchi gialli "Scripting" prima del verde "First Contentful Paint". Se uno script è caricato con
deferoasync, la sua esecuzione sarà dopo l'FCP. Lighthouse in modalità "Performance" mostrerà la raccomandazione "Rimuovi le risorse che bloccano il rendering": in quella lista non devono comparire script.
Vale la pena cambiare approccio di caricamento adesso
Se i tuoi script restano ancora nell'<head> senza attributi, stai perdendo posizionamento sui motori di ricerca e infastidendo gli utenti. Non è un'ipotesi, Lighthouse e PageSpeed Insights mostrano il problema in rosso nelle prime righe del report.
Partenza rapida: passa in rassegna i tag <script> nel tuo template, aggiungi defer per il codice principale e async per la metrica. Ci vogliono cinque minuti, e l'LCP può migliorare di 300-500 ms. Poi, import() dinamico per i componenti pesanti quando avrai modo di fare refactoring.
Lascia un solo metodo di caricamento, <script defer> nell'<head>, e la pagina si caricherà senza ritardi visibili. Testalo sul tuo progetto oggi stesso.



