The Complete Guide to <script> Loading in Modern Web Apps: async, defer, and ES Modules

Published: (January 6, 2026 at 09:14 PM EST)
6 min read
Source: Dev.to

Source: Dev.to

Table of Contents

Why script‑loading strategy matters

Script tags can block HTML parsing, delay interactivity, and alter event timing. Choosing the right strategy:

  • Improves Core Web Vitals (especially FID/INP and LCP)
  • Prevents race conditions and brittle dependencies
  • Enables scalable architecture with import/export and code‑splitting

The four ways to load scripts

1️⃣ Classic (blocking)

  • Blocks HTML parsing at the tag while downloading and executing.
  • Execution order follows the HTML order.
  • Use only when you truly need immediate execution during parsing.

2️⃣ async (classic)

  • Downloads in parallel; executes as soon as it’s ready.
  • Execution order is non‑deterministic (download‑completion order).
  • Ideal for independent scripts: analytics, ads, beacons.

3️⃣ defer (classic)

  • Downloads in parallel; executes after the HTML is fully parsed and before DOMContentLoaded.
  • Preserves HTML order.
  • Best classic choice for app code when you can’t use modules.

4️⃣ ES Modules (type="module")

  • Defer‑like by default for entry scripts, plus dependency‑aware loading.
  • Scoped (no accidental globals), strict mode, import/export, dynamic import(), top‑level await, import maps.
  • The default for modern apps.

How parsing, execution, and events interact

Loading typeParsing impactExecution timingOrder guarantee
Classic (blocking)Stops HTML parsing at the tagImmediately after downloadHTML order
asyncDoes not block parsingAs soon as the script finishes downloadingNone (download order)
deferDoes not block parsingAfter parsing completes, before DOMContentLoadedHTML order
Module (entry)Does not block parsingAfter parsing completes, before DOMContentLoaded (defer‑like)Dependency graph order

Event timing

  • DOMContentLoaded fires after all deferred and module entry scripts have executed, and after any async scripts that started before DOMContentLoaded finish.
  • load fires after all resources (images, CSS, etc.) have finished loading.

Choosing the right approach

  • Application codetype="module" (default).
  • Classic scripts when modules aren’t an option → defer.
  • Independent third‑party scriptsasync.
  • Avoid blocking classic scripts—they stall rendering and interactivity.

ES Modules deep dive

Imports and exports

// app.js
export function init() {
  console.log('App started');
}

// main.js
import { init } from './app.js';
init();

Inline module


  import { init } from '/js/app.js';
  init();

Dynamic import() for on‑demand code

const btn = document.querySelector('#showChart');
btn.addEventListener('click', async () => {
  const { renderChart } = await import('./chart.js');
  renderChart();
});

Top‑level await

// config.js
export const cfg = await fetch('/api/config')
  .then(r => r.json());

// main.js
import { cfg } from './config.js';
console.log('Config:', cfg);

Module scope and strict mode

// my-module.js
const secret = 42;               // Not on window
export const api = { hello: 'world' };

// Expose globally only if you must:
window.myApi = { doSomething() { /* … */ } };

Import maps for bare specifiers


{
  "imports": {
    "lodash": "/vendor/lodash-es/lodash.js",
    "@app/": "/static/app/"
  }
}

  import _ from 'lodash';
  import util from '@app/util.js';
  console.log(_.chunk([1,2,3,4], 2), util);

Modules in Web Workers

// main thread
const worker = new Worker('/js/worker.js', { type: 'module' });

// worker.js
import { heavyCompute } from './heavy.js';
self.onmessage = e => postMessage(heavyCompute(e.data));

Performance techniques

Use resource hints thoughtfully

Caching and bundling

  • Use long‑term caching with content hashes for production assets.
  • Even with HTTP/2/3, bundling/chunking reduces request overhead and improves cache‑hit rates.
  • Leverage dynamic import() for lazy‑loading large, rarely‑used modules.

Code‑splitting & lazy loading

// router.js – load route component only when needed
export async function loadDashboard() {
  const { Dashboard } = await import('./pages/dashboard.js');
  return new Dashboard();
}

Minify & compress

  • Serve JavaScript as gzip or Brotli (br).
  • Use tools like esbuild, SWC, or Terser for minification.

Avoid duplicate work

  • Do not load the same script both as classic and as a module.
  • Consolidate third‑party libraries into a single bundle when order matters.

DOM timing patterns

PatternWhen to useExample
DOMContentLoaded listenerCode that needs the full DOM but not external resourcesdocument.addEventListener('DOMContentLoaded', initApp);
load listenerCode that depends on images, fonts, or other mediawindow.addEventListener('load', () => { /* layout calculations */ });
requestIdleCallbackNon‑critical work that can be deferred until the main thread is idlerequestIdleCallback(() => { prefetchData(); });
setTimeout(..., 0)Micro‑task queue flush; rarely needed with modern async patternssetTimeout(() => console.log('next tick'), 0);

Security and compliance

  • type="module" scripts are automatically strict mode and have module‑scope (no accidental globals).
  • Use Subresource Integrity (SRI) for third‑party scripts:
  • Set Content‑Security‑Policy (script-src) to whitelist trusted origins and disallow unsafe-inline.
  • Prefer nonce or hash based CSP for inline modules when necessary.

Common pitfalls

  1. Mixing async and defer on the same script – the last attribute wins; avoid confusion.
  2. Relying on global variables from modules – modules are scoped; expose only what you need via export.
  3. Forgetting to add type="module" to dynamically created <script> elements – they default to classic.
  4. Using document.write after a module has started loading – can abort module loading in some browsers.
  5. Assuming modulepreload works in all browsers – provide a fallback <script> for older browsers.

Copy‑paste templates

Classic blocking (rarely needed)

Async third‑party

Defer classic app bundle

ES Module entry point

Inline module with CSP nonce


  import { init } from '/js/app.js';
  init();

Import map (HTML5)


{
  "imports": {
    "react": "/libs/react/react.production.min.js",
    "react-dom": "/libs/react/react-dom.production.min.js"
  }
}

Final takeaways

  • Default to ES Modules for any new code; they give you defer‑like loading, strict mode, and a clean dependency graph.
  • Use defer only when you must support classic scripts in environments that don’t understand modules.
  • Reserve async for truly independent third‑party resources that don’t need DOM readiness or ordering.
  • Pair the right loading strategy with resource hints, caching, and CSP/SRI to maximise performance and security.

By consciously selecting the appropriate script‑loading technique, you’ll deliver faster, more reliable, and more secure web experiences.

Execution Order and Examples

async vs defer

Modules and Dependency Order

// entry.js
import './a.js'; // a.js imports b.js
console.log('entry');

// a.js
import './b.js';
console.log('a');

// b.js
console.log('b');

Execution order: b → a → entry

DOM‑Timing Patterns

Safe DOM access with defer / modules

// app.js
document.querySelector('#btn').addEventListener('click', () => {
  console.log('clicked');
});

Async script that must wait for the DOM

// metrics.js
window.addEventListener('DOMContentLoaded', () => {
  // Safe to query the DOM
});

Security and Compliance

Headers, CORS, MIME

  • Serve JavaScript with the correct MIME type: application/javascript.
  • ES Modules obey CORS; cross‑origin imports require proper CORS headers.
  • Avoid file:// URLs for modules; use a local HTTP(S) server.

CSP and Subresource Integrity (SRI)

Enterprise note: Before adding third‑party CDNs, libraries, or analytics, verify they align with your organization’s security, privacy, and compliance guidelines.

Common Pitfalls

Missing .js extension in browser imports

// ❌ Wrong
import { x } from './utils';

// ✅ Correct
import { x } from './utils.js';

Import‑map placement





Other gotchas

  • Assuming async preserves order. It doesn’t—don’t chain logic across multiple async scripts expecting sequence.
  • A top‑level await in a widely shared module can delay the entire app. Prefer lazy loading where practical.

Copy‑Paste Templates

Modern app with modules + legacy fallback


  
  Modern Module App
  

  

  
  {
    "imports": {
      "@app/": "/js/"
    }
  }
  

  
  

Classic with defer


  
  Classic Defer

  Click

  
  

Async for independent third‑party script


  
  Async Example
  

  

Final Takeaways

  • Prefer type="module" for modern development: scalable architecture, safer scoping, and built‑in code splitting.
  • Use defer for classic scripts when modules aren’t feasible.
  • Use async exclusively for independent, order‑agnostic scripts.
  • Mind event timing, caching, and security (CSP, SRI, CORS).
  • Employ modulepreload, dynamic imports, and import maps as your app grows.

If you plan to use third‑party CDNs or libraries, ensure they conform to your organization’s internal security and compliance requirements.

Back to Blog

Related posts

Read more »

Htmx: High Power Tools for HTML

Article URL: https://github.com/bigskysoftware/htmx Comments URL: https://news.ycombinator.com/item?id=46524527 Points: 9 Comments: 1...