Skip to content

Everything for WordPress, web development — and beyond

⚡ How to load external JavaScript without blocking the page

⚡ 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 async and defer attributes: 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:

1function 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

async

Parallel with parsing

Immediately after loading

Not guaranteed

defer

Parallel with parsing

After full HTML parsing

Guaranteed (as in document)

Rule of thumb:

  • async for independent scripts: analytics, ads, counters. They don't need the DOM, they don't care about order.
  • defer for 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
2button.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

<script src>

Yes

No

Guaranteed

Not used unless necessary

Dynamic loadScript

No

Yes

Via .then() chain

Conditional loading, heavy dependencies

<script async>

No

No

Not guaranteed

Analytics, ads, counters

<script defer>

No

No

Guaranteed

Main application, DOM manipulation

import()

No

Yes (ES module)

Via await

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 async executes the script immediately after the file loads, even if HTML isn't fully parsed yet, and without order guarantee. defer always waits for full DOM readiness and preserves script sequence as in HTML. For main application code, use defer, for isolated counters, use async.

Can you combine dynamic loading with defer?

Yes, this is a common scenario. The application core loads with defer in <head>, it initializes the interface. Heavy or rarely used modules are pulled via dynamic loadScript() or import() 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 defer or async via wp_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 the async attribute. Complex interactive blocks (calculators, filters) should be moved to dynamic import() inside a custom module.

Does this work with third-party scripts like Google Analytics?

Yes. The GA4 tag gtag.js loads with async by 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 use async. If it needs the DOM, use defer or 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 defer or async, 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.