
⚡ How to load external JavaScript without blocking the page
When the browser encounters a <script> tag without attributes, it drops everything. Page rendering comes to a complete halt until the script loads and executes. On a slow 4G connection, that's 2-3 seconds of a blank screen.
The user has already left for a competitor by then. Core Web Vitals record a failed LCP, Google pushes the page down in search results, and you lose traffic and conversions. Meanwhile, the problem can be solved with three lines if you know where to look.
Below is a working method to load external JavaScript without blocking. From the classic two-file approach to modern async/defer and dynamic import(). With proven code you can copy and paste.
💡 Quick overview:
- Understand the problem: how a regular
<script>blocks HTML parsing and kills loading speed - Master the classic approach: a tiny loader (≤300 bytes) dynamically pulls in the main JS file
- Learn the native
asyncanddeferattributes: when and which one to use - Explore dynamic
import()for loading modules on demand - Choose a strategy for your project with a comparison table
Why JavaScript blocks rendering
When the HTML parser reaches <script src="app.js">, it does exactly three things: stops parsing the document, downloads the file, executes it. Only after that does it continue building the DOM.
The reason is architectural. A script can contain document.write(), which changes HTML on the fly. The browser doesn't know in advance whether there's such a call, so it plays it safe and waits for full loading and execution. The result: even a lightweight 5 KB script adds hundreds of milliseconds to First Contentful Paint from a single network round-trip alone.
The problem isn't new. Back in 2009, Nicholas Zakas described a technique for dynamic non-blocking JavaScript loading, and it still works today, albeit with adjustments for modern APIs. With the arrival of async, defer, and ES modules, developers now have a whole toolkit. Let's examine each one.
Classic approach: two files and dynamic loading
The idea is simple. Instead of putting all your JS in one file and attaching it to the page via <script src="...">, you split the code into two parts:
- A tiny loader (200-300 bytes after compression)
- The main file with application logic
The loader is inserted inline at the bottom of the page, right before </body>. It creates a <script> programmatically and adds it to the DOM, such a tag no longer blocks parsing because it appears outside the main document flow. As soon as the main file loads, initialization executes.
Modern version of the function in pure JS without IE backwards compatibility:
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 }
Nine lines. No readyState checks, no branches for old IE, no pyramid of doom callbacks. Just a function returning a Promise, convenient to combine with async/await.
Usage on the page looks like this (code at the bottom, before the closing </body>):
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>
The first script (inline) is the loader. It parses and executes instantly because it's less than 300 bytes. The second script (app.js) loads asynchronously and doesn't interfere with rendering.
What if you have more than two files? Combine them during the build. Modern bundlers like Vite and Webpack do this automatically: tree-shaking, code splitting, minification in one pass. Manually managing the load order of a dozen files is a path to race conditions and errors.
async and defer: native unblocking
HTML5 gave us two attributes that solve the problem without a single line of JavaScript:
1 <script async src="analytics.js"></script> 2 <script defer src="app.js"></script>
Both load the file in parallel with HTML parsing. The difference is in the execution timing:
Attribute | Loading | Execution | Order |
|---|---|---|---|
| Parallel with parsing | Immediately after loading | Not guaranteed |
| Parallel with parsing | After full HTML parsing | Guaranteed (as in document) |
Rule of thumb:
asyncfor independent scripts: analytics, ads, counters. They don't need the DOM, they don't care about order.deferfor the main application: DOM manipulation, interface initialization. The script waits for page readiness and executes in the correct sequence.
In practice, the combination is simple: put defer on all scripts in <head>, and they behave as if they're at the bottom of the page but load earlier. No magic, just the browser scheduler.
And yes, you can combine it with dynamic loading. For example, load the application core via <script defer>, and attach heavy widgets dynamically through loadScript() only when they're actually needed.
Dynamic import(): modules on demand
ES2020 brought dynamic import(), a native way to load a module asynchronously, without a bundler and without extra functions:
1 // Loads only when user clicked 2 button.addEventListener('click', async () => { 3 const { heavyChart } = await import('./chart-component.js'); 4 heavyChart.render(); 5 });
The import() call returns a Promise. The module loads in the background, parsing isn't blocked, the page stays responsive. Code inside the module executes in strict mode and in its own scope, name conflicts are eliminated.
This is the ideal tool for code splitting without a bundler. Heavy components (charts, editors, maps) are moved to separate files and loaded on first interaction. A user who never opens a chart doesn't pay for it with traffic and loading time.
Comparing approaches
Each method has its niche. To avoid guessing, we compiled the characteristics into a table:
Approach | Blocks rendering | Requires JS | Execution order | For which scripts |
|---|---|---|---|---|
| Yes | No | Guaranteed | Not used unless necessary |
Dynamic | No | Yes | Via | Conditional loading, heavy dependencies |
| No | No | Not guaranteed | Analytics, ads, counters |
| No | No | Guaranteed | Main application, DOM manipulation |
| No | Yes (ES module) | Via | Code splitting, on-demand components |
Main takeaway: don't fixate on one method. A typical production setup uses two or three simultaneously: defer for the core, async for metrics, dynamic import() for heavy components.
Short demo video on the topic, breakdown of async and defer with loading timeline visualization:
⁉️🤔 Frequently asked questions
How does async differ from defer in practice?
Both don't block parsing during loading. But
asyncexecutes the script immediately after the file loads, even if HTML isn't fully parsed yet, and without order guarantee.deferalways waits for full DOM readiness and preserves script sequence as in HTML. For main application code, usedefer, for isolated counters, useasync.
Can you combine dynamic loading with defer?
Yes, this is a common scenario. The application core loads with
deferin<head>, it initializes the interface. Heavy or rarely used modules are pulled via dynamicloadScript()orimport()on user interaction. This way you get both fast startup and deferred loading of secondary code.
What should I use for a WordPress site?
WordPress automatically adds
deferorasyncviawp_enqueue_script()if you pass the appropriate argument in the fifth parameter:wp_enqueue_script('my-script', $url, [], null, ['strategy' => 'defer']). For third-party scripts (Google Analytics, ads), the easiest approach is theasyncattribute. Complex interactive blocks (calculators, filters) should be moved to dynamicimport()inside a custom module.
Does this work with third-party scripts like Google Analytics?
Yes. The GA4 tag
gtag.jsloads withasyncby default, so it doesn't block the page. For other third-party services, check the documentation: if the script doesn't require a ready DOM and doesn't depend on load order, feel free to useasync. If it needs the DOM, usedeferor dynamic loading with a callback.
How do I verify that a script really doesn't block the page?
Open Chrome DevTools → Performance → Record → reload the page. On the timeline, look for yellow "Scripting" blocks before the green "First Contentful Paint". If a script is loaded with
deferorasync, its execution will be after FCP. Lighthouse in "Performance" mode will show the "Remove render-blocking resources" recommendation, there should be no scripts in that list.
Is it worth changing your loading approach right now
If your scripts still hang in <head> without attributes, you're losing search rankings and annoying users. This isn't a hypothesis, Lighthouse and PageSpeed Insights show the problem in red in the first lines of the report.
Quick start: go through the <script> tags in your template, add defer for main code and async for metrics. It takes five minutes, and LCP can improve by 300-500 ms. Next, dynamic import() for heavy components when you get around to refactoring.
Leave one loading method, <script defer> in <head>, and the page will load without visible delays. Test it on your project today.



