Skip to content

JavaScript Lazy Loading — Deferring Script Execution for Faster Pages

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about JavaScript Lazy Loading. We cover key concepts, practical examples, and best practices to help you master this topic.

JavaScript lazy loading defers script execution until needed, reducing blocking time on the main thread and improving interactivity metrics.

What You'll Learn

By the end of this tutorial, you'll understand how JavaScript blocks rendering and Parsing, how to use defer and async attributes effectively, how to lazy-load scripts based on viewport and interaction, and how to manage script execution order.

Why It Matters

JavaScript is the most expensive resource on the web. Every <script> tag without defer or async blocks HTML parsing, delays rendering, and competes for the main thread. A single 200KB script can delay interactivity by 2-3 seconds. Lazy loading non-critical scripts — analytics, chatbots, social widgets, below-fold components — dramatically improves Time to Interactive and First Input Delay.

Real-World Use

A blog loads core JavaScript (navigation, search) with defer, lazy loads the comment section script only when the user scrolls to it, and loads the chat widget only after 10 seconds of inactivity. Time to Interactive drops from 4.2s to 1.8s, and First Input Delay improves from 150ms to 25ms.

Script Loading Strategies

graph LR
    A[Script Loading] --> B[Blocking
Parses then executes] A --> C[Defer
Parses in parallel, executes after] A --> D[Async
Parses in parallel, executes when ready] A --> E[Dynamic Import
Loads on demand] A --> F[Intersection-based
Loads when visible] A --> G[Interaction-based
Loads on hover/click] B --> H[Blocks HTML parsing
Hurts LCP and FID] C --> I[Preserves order
Executes before DOMContentLoaded] D --> J[Order not guaranteed
Executes when downloaded] E --> K[Chunked by route/component] style A fill:#4a90d9,color:#fff style B fill:#e74c3c,color:#fff style C fill:#27ae60,color:#fff style D fill:#f39c12,color:#fff

Defer vs Async

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Defer vs Async Examples</title>

    <!-- Defer: download in parallel, execute in order after HTML parsed -->
    <!-- Best for: scripts that depend on DOM and other deferred scripts -->
    <script src="/js/core.js" defer></script>
    <script src="/js/vendor.js" defer></script>
    <script src="/js/app.js" defer></script>

    <!-- Async: download in parallel, execute as soon as downloaded -->
    <!-- Best for: independent scripts like analytics -->
    <script src="/js/analytics.js" async></script>
    <script src="/js/chat-widget.js" async></script>

    <!-- Blocking (no attribute): download and execute immediately -->
    <!-- Only for tiny inline scripts needed before rendering -->
    <script>
        // Critical inline script — this blocks rendering
        document.documentElement.classList.add('js-enabled');
    </script>

    <!-- Scripts are loaded in this order:
         1. critical inline (blocks)
         2. async scripts (in parallel, execute when ready)
         3. deferred scripts (in parallel, execute after parsing in order)
    -->
</head>
<body>
    <h1>JavaScript Loading Strategies</h1>
    <p>Check the Network tab to see loading order.</p>
</body>
</html>

Dynamic Script Loading Utility

// utils/script-loader.js — Dynamic script loading manager
class ScriptLoader {
    constructor() {
        this.loaded = new Map();
        this.pending = new Map();
    }

    // Load a single script with promise-based API
    loadScript(src, options = {}) {
        if (this.loaded.has(src)) {
            return Promise.resolve(this.loaded.get(src));
        }

        if (this.pending.has(src)) {
            return this.pending.get(src);
        }

        const promise = new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = src;

            if (options.async !== undefined) script.async = options.async;
            if (options.defer !== undefined) script.defer = options.defer;
            if (options.module) script.type = 'module';
            if (options.crossorigin) script.crossOrigin = options.crossorigin;
            if (options.integrity) script.integrity = options.integrity;

            // Insert at specific position
            if (options.before) {
                options.before.parentNode.insertBefore(script, options.before);
            } else {
                document.body.appendChild(script);
            }

            script.onload = () => {
                this.loaded.set(src, script);
                this.pending.delete(src);
                console.log(`Script loaded: ${src}`);
                resolve(script);
            };

            script.onerror = () => {
                this.pending.delete(src);
                console.error(`Script failed: ${src}`);
                reject(new Error(`Script load error: ${src}`));
            };
        });

        this.pending.set(src, promise);
        return promise;
    }

    // Load multiple scripts sequentially (maintain order)
    async loadSequential(scripts) {
        const results = [];
        for (const script of scripts) {
            const result = await this.loadScript(script.src, script.options);
            results.push(result);
        }
        return results;
    }

    // Load multiple scripts in parallel
    loadParallel(scripts) {
        return Promise.all(
            scripts.map(s => this.loadScript(s.src, s.options))
        );
    }

    // Load script when element enters viewport
    loadOnIntersection(src, element, options = {}) {
        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    this.loadScript(src, options);
                    observer.disconnect();
                }
            });
        }, { rootMargin: options.rootMargin || '200px' });

        observer.observe(element);
        return observer;
    }

    // Load script on interaction event
    loadOnInteraction(src, element, event = 'mouseenter', options = {}) {
        const handler = () => {
            this.loadScript(src, options);
            element.removeEventListener(event, handler);
        };
        element.addEventListener(event, handler, { once: true });
    }

    // Preload a script (download but don't execute) for future use
    preloadScript(src) {
        const link = document.createElement('link');
        link.rel = 'preload';
        link.as = 'script';
        link.href = src;
        document.head.appendChild(link);
    }

    // Check if a script is already loaded
    isLoaded(src) {
        return this.loaded.has(src) ||
            document.querySelector(`script[src="${src}"]`) !== null;
    }
}

const scriptLoader = new ScriptLoader();

// Load core scripts after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
    scriptLoader.loadScript('/js/navigation.js', { defer: true });
    scriptLoader.loadScript('/js/search.js', { defer: true });
});

// Lazy load comment script when comment section is near viewport
const commentSection = document.getElementById('comments');
scriptLoader.loadOnIntersection('/js/comments.js', commentSection, {
    rootMargin: '400px'
});

// Load chat widget only after 10 seconds of inactivity
let inactivityTimer;
const loadChatAfterDelay = () => {
    clearTimeout(inactivityTimer);
    inactivityTimer = setTimeout(() => {
        scriptLoader.loadScript('/js/chat.js', { async: true });
    }, 10000);
};

document.addEventListener('mousemove', loadChatAfterDelay, { once: true });
document.addEventListener('scroll', loadChatAfterDelay, { once: true });
document.addEventListener('touchstart', loadChatAfterDelay, { once: true });

// Preload the next page's script on hover
document.querySelectorAll('a[data-preload]').forEach(link => {
    link.addEventListener('mouseenter', () => {
        scriptLoader.preloadScript('/js/next-page.js');
    }, { once: true });
});

Module-Based Lazy Loading

// Load ES modules dynamically — modern approach
const moduleLoader = {
    async load(path) {
        try {
            const module = await import(path);
            console.log(`Module loaded: ${path}`);
            return module;
        } catch (error) {
            console.error(`Module error: ${path}`, error);
            throw error;
        }
    },

    async loadWithDependencies(main, deps = []) {
        // Load dependencies in parallel
        const depResults = await Promise.all(
            deps.map(dep => this.load(dep))
        );

        // Load main module after deps are ready
        const mainModule = await this.load(main);
        return { main: mainModule, dependencies: depResults };
    }
};

// Usage: load chart module only when user clicks "Show Chart"
document.getElementById('show-chart').addEventListener('click', async () => {
    try {
        // Dynamic import — browser does the lazy loading
        const { Chart, registerables } = await import('https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.esm.js');

        Chart.register(...registerables);

        const canvas = document.getElementById('myChart');
        new Chart(canvas, {
            type: 'bar',
            data: {
                labels: ['Jan', 'Feb', 'Mar'],
                datasets: [{
                    label: 'Sales',
                    data: [120, 190, 300]
                }]
            }
        });

        console.log('Chart rendered successfully');
    } catch (error) {
        console.error('Failed to load chart:', error);
        document.getElementById('chart-error').hidden = false;
    }
});

Lazy Loading Third-Party Scripts

// utils/third-party-loader.js — Manage third-party scripts
class ThirdPartyManager {
    constructor() {
        this.loaded = new Set();
    }

    // Load after user interaction (consent-friendly)
    loadOnConsent(src, config = {}) {
        const handler = () => {
            if (this.loaded.has(src)) return;
            this.loaded.add(src);

            const script = document.createElement('script');
            script.src = src;
            script.async = true;

            if (config.dataset) {
                Object.entries(config.dataset).forEach(([k, v]) => {
                    script.dataset[k] = v;
                });
            }

            document.body.appendChild(script);
            console.log(`Third-party loaded: ${src}`);

            // Clean up event listeners
            document.removeEventListener('click', handler);
            document.removeEventListener('scroll', handler);
            document.removeEventListener('touchstart', handler);
        };

        // Load on first user interaction
        document.addEventListener('click', handler, { once: true });
        document.addEventListener('scroll', handler, { once: true });
        document.addEventListener('touchstart', handler, { once: true });

        // Fallback: load after 15 seconds regardless
        setTimeout(handler, 15000);
    }

    // Load below-fold third-party widgets
    loadOnIntersection(src, widgetElement) {
        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const script = document.createElement('script');
                    script.src = src;
                    script.async = true;
                    widgetElement.appendChild(script);
                    observer.disconnect();
                }
            });
        }, { rootMargin: '200px' });

        observer.observe(widgetElement);
    }
}

const thirdParty = new ThirdPartyManager();

// Suspend analytics until user interacts
thirdParty.loadOnConsent('https://plausible.io/js/script.js', {
    dataset: { domain: 'example.com' }
});

// Lazy load social media feed widget
const twitterFeed = document.getElementById('twitter-feed');
thirdParty.loadOnIntersection('https://platform.twitter.com/widgets.js', twitterFeed);

Common Mistakes

  1. Using async when defer is appropriate. Async executes scripts as soon as they download, which can happen before the DOM is ready. Use defer for scripts that need the DOM. Use async only for fully independent scripts.
  2. Not handling load failures. A lazy-loaded script that fails should not break the page. Always wrap dynamic loads in try/catch and provide fallback UI.
  3. Loading everything with async. If you put async on all scripts, execution order becomes unpredictable. Scripts that depend on each other will break intermittently.
  4. Lazy loading above-fold scripts. Scripts needed for hero interactions, navigation, or above-fold functionality should load immediately or with defer. Lazy loading critical scripts delays interactivity.
  5. Forgetting about noscript users. If JavaScript is disabled, all lazy loading fails. Provide <noscript> fallbacks or server-rendered alternatives for essential functionality.

Practice Questions

  1. What is the difference between defer, async, and a blocking script tag?
  2. When should you use dynamic import instead of script tags?
  3. How does lazy loading JavaScript improve First Input Delay?
  4. Why should third-party scripts be lazy loaded on interaction?
  5. How do you handle errors in dynamically loaded scripts?

Challenge: Build a page with three tiers of JavaScript loading: critical scripts (defer), below-fold scripts (intersection Observer), interaction-triggered scripts (click/hover). Include a chat widget that loads after 10 seconds of inactivity, an analytics script that loads on first user gesture, and a heavy chart library that loads only when the user clicks "Show Chart". Measure the impact on TTI and FID.

FAQ

Is defer better than async for most scripts?

Yes. Defer preserves execution order and waits for the DOM to be ready. Async executes out of order and is best for independent scripts like analytics where order doesn't matter.

Can lazy loading JavaScript hurt SEO?

Google processes deferred and async scripts the same as blocking scripts. Dynamic lazy loading based on interaction or viewport may delay content that Google's crawler expects. Ensure critical content and links are server-rendered.

How do I handle script caching with lazy loading?

Use versioned URLs (e.g., /js/app.v3.js) or content-hash filenames. Browsers cache scripts based on URL, so changing the URL = new version. Avoid cache-busting query params that change every build.

{{< faq "What about module/nomodule pattern for legacy browsers?" "Use <script type=\"module\" src=\"modern.js\"> and <script nomodule src=\"legacy.js\">. Modern browsers load the module version; legacy browsers ignore it and load the nomodule fallback." >}}

Does HTTP/2 make script lazy loading less important?

HTTP/2 helps with parallel downloads but doesn't solve main thread blocking. Lazy loading reduces the amount of JavaScript parsed and executed on initial load, which directly improves interactivity regardless of the transport protocol.

Mini Project

Build a JavaScript loading dashboard: create a page with 8+ scripts of varying sizes and categories (core, analytics, chat, widgets, below-fold components). Implement a tiered loading Strategy with defer, async, intersection-based, and interaction-based loading. Measure network usage, main thread blocking time, TTI, and FID for each loading strategy. Visualize the results as a comparison table.

What's Next

You've mastered JavaScript lazy loading. Next, learn about Video Lazy Loading to defer video content and reduce initial page weight.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro