01 — Purpose

Structure that scales with the team

JavaScript architecture affects maintainability, testability, and what users download — regardless of framework.

One giant bundle with hidden globals does not scale past a handful of developers. Module boundaries, loading only what each page needs, and progressive enhancement keep behaviour understandable and performance predictable — whether you ship static HTML, a multi-page app, or a client-rendered SPA.

See JavaScript standard and JavaScript cost for delivery expectations.

02 — Principles

Modular and understandable

Code should be reusable, testable, and isolated — not centralised by default.

  • ES modules with explicit imports — dependencies visible at the top
  • one clear responsibility per module — header nav, syntax highlighting, form validation
  • progressive enhancement — script enhances what HTML already does
  • independently testable units — pure functions where possible

03 — Practice

Module patterns that work

Load less, load later, and initialise once — on any stack.

  • include scripts only on pages that need them — not one app.js on every template
  • use type="module" and defer for enhancement scripts — parse after HTML, run in order, without blocking render
  • use dynamic import() for heavy optional features — syntax highlighters, charts, maps, admin-only tools
  • guard initialisation — skip if DOM nodes are missing or already bound
  • avoid global state — pass configuration or read from DOM data attributes
  • co-locate small modules with the feature or component that uses them

How you load scripts

Default enhancement scripts to native ES modules with defer. The browser downloads them in parallel with parsing, executes them after the document is parsed, and preserves order between deferred scripts — a good fit for progressive enhancement without blocking first paint.

<script type="module" src="/scripts/site-header.js" defer></script>
<!-- Page-specific module — only on templates that need it -->
<script type="module" src="/scripts/checkout.js" defer></script>

Use async only when order does not matter — analytics snippets, independent widgets. Do not sprinkle async on modules that depend on each other or on DOM readiness unless you handle that explicitly inside the module.

Per-page and per-feature modules

Split by route, layout, or feature — header behaviour on layouts with a header; validation on forms; nothing on purely static articles. In React, Vue, Svelte, or similar apps, the same idea applies as route-based code splitting: the shell loads once; route chunks load when navigated to.

Dynamic import for optional weight

If a feature is heavy and not everyone needs it, load it when the user hits the condition — a page with code blocks, opening a chart tab, entering an admin area. Keep the static path free of that cost.

async function initSyntaxHighlighting() {
    const { highlightAll } = await import('./prism.js');
    highlightAll();
}

if (document.querySelector('pre code')) {
    initSyntaxHighlighting();
}

Initialisation without double-binding

Run setup when the DOM is ready, and make init idempotent — set a data attribute or flag so client navigations and hot reload do not attach duplicate listeners.

function initSiteHeader() {
    const header = document.querySelector('.site-header');
    if (!header || header.dataset.navBound === 'true') return;

    header.dataset.navBound = 'true';
    // attach listeners…
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initSiteHeader);
} else {
    initSiteHeader();
}

SPAs and meta-frameworks have their own lifecycle hooks — React effects, Vue onMounted, Svelte onMount, router afterNavigate, and so on. The goal is the same: one module owns the feature, init runs when the view is in the DOM, and guards prevent duplicate work.

If you use Astro

Astro’s client router fires astro:page-load after each navigation (including the first load). Listen there for enhancements that must re-run when swapped HTML arrives — and still call init on the first paint for static output:

document.addEventListener('astro:page-load', initSiteHeader);
initSiteHeader();

This site uses that pattern for shared chrome (for example header navigation). You do not need Astro to follow the rest of this page — DOMContentLoaded plus guards is enough on traditional multi-page sites.

04 — Avoid

Complexity centralisation

Giant shared files and blocking scripts hide dependencies until production breaks.

  • monolithic app.js imported on every page
  • render-blocking scripts without defer or a documented reason
  • static import of large libraries on pages that never use them — prefer dynamic import()
  • hidden dependencies via window globals or implicit load order
  • duplicated utility logic copy-pasted across features
  • importing framework runtime or hydration for content that needs no client behaviour

05 — Close

Reduce complexity

JavaScript structure should make the next change easier — not riskier.

Before adding a dependency or global script, ask what module owns it, which pages load it, whether defer or dynamic import is enough, and what happens on routes that do not need it. Default to less JavaScript.

Related: hydration costs, lazy loading strategy, and JavaScript code review checklist.