JavaScript Essentials

Web Workers & Performance

45 min Lesson 53 of 60

Understanding the Main Thread Bottleneck

JavaScript in the browser runs on a single thread called the main thread. This one thread is responsible for everything you see and interact with: parsing HTML, calculating CSS styles, executing JavaScript, handling user input like clicks and scrolls, painting pixels to the screen, and running garbage collection. When any one of these tasks takes too long, everything else must wait. The result is what users experience as lag, stutter, or a completely frozen interface.

Consider what happens when you run a computationally expensive operation like sorting a million records, calculating complex mathematical sequences, or processing a large image pixel by pixel. While that JavaScript code is running, the browser cannot respond to button clicks, scroll events, or even repaint the screen. The user sees a frozen page, and if the task takes longer than a few seconds, the browser may display a "page unresponsive" warning dialog.

This single-threaded limitation is by design. The DOM (Document Object Model) is not thread-safe, meaning that if multiple threads tried to modify the DOM at the same time, the results would be unpredictable and chaotic. However, this design creates a serious challenge: how do you run heavy computations without blocking the user interface? The answer is Web Workers.

What Are Web Workers?

Web Workers are a browser API that allows you to run JavaScript code in a separate background thread, completely independent of the main thread. A Web Worker has its own execution context, its own event loop, and its own memory space. Code running inside a worker does not block the main thread, which means the user interface remains responsive even while heavy computations are running in the background.

Think of it this way: the main thread is like a chef in a restaurant who must take orders, cook food, serve tables, and clean dishes. If a complex dish takes 30 minutes to prepare, every other task grinds to a halt. Web Workers are like hiring additional kitchen staff -- the complex dish can be prepared by a dedicated cook while the main chef continues serving other customers.

Note: Web Workers run in a completely isolated environment. They cannot access the DOM, the window object, the document object, or any UI-related APIs. Their sole purpose is computation and data processing, and they communicate with the main thread exclusively through a messaging system.

Creating Your First Web Worker

To create a Web Worker, you need two files: your main script and a separate worker script file. The worker script contains the code that will run in the background thread. You create a worker by passing the path to the worker script to the Worker constructor.

Example: Main Script (main.js)

// Create a new Worker by specifying the worker script file
const worker = new Worker('worker.js');

// Send data to the worker
worker.postMessage({ task: 'fibonacci', number: 45 });

// Listen for messages (results) from the worker
worker.onmessage = function(event) {
    console.log('Result from worker:', event.data);
    document.getElementById('result').textContent = event.data.result;
};

// Handle errors that occur inside the worker
worker.onerror = function(error) {
    console.error('Worker error:', error.message);
    console.error('In file:', error.filename, 'at line:', error.lineno);
};

Example: Worker Script (worker.js)

// Listen for messages from the main thread
self.onmessage = function(event) {
    const data = event.data;

    if (data.task === 'fibonacci') {
        const result = calculateFibonacci(data.number);
        // Send the result back to the main thread
        self.postMessage({ result: result, number: data.number });
    }
};

function calculateFibonacci(n) {
    if (n <= 1) return n;
    return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
}

The Worker constructor accepts a URL string pointing to the worker script file. Inside the worker, the global scope is self (not window). The worker listens for messages using self.onmessage and sends data back using self.postMessage. On the main thread side, you use worker.postMessage to send data and worker.onmessage to receive results.

The postMessage and onmessage Communication Pattern

Communication between the main thread and a worker happens exclusively through the message passing pattern. You cannot share variables directly between the two threads. Instead, you send data using postMessage() and receive it through the onmessage event handler. The data is structured cloned, meaning the browser creates a deep copy of the data. The original and the copy are completely independent -- modifying one does not affect the other.

Example: Two-Way Communication

// main.js
const worker = new Worker('data-processor.js');

// Send an array of numbers to process
worker.postMessage({
    action: 'sort',
    data: [45, 12, 89, 3, 67, 23, 91, 8, 56, 34]
});

// Send another task while the first one is still processing
worker.postMessage({
    action: 'statistics',
    data: [100, 200, 150, 300, 250, 175, 225]
});

worker.onmessage = function(event) {
    const response = event.data;
    if (response.action === 'sort') {
        console.log('Sorted:', response.result);
    } else if (response.action === 'statistics') {
        console.log('Mean:', response.result.mean);
        console.log('Median:', response.result.median);
    }
};

// data-processor.js (worker)
self.onmessage = function(event) {
    const { action, data } = event.data;

    if (action === 'sort') {
        const sorted = data.slice().sort(function(a, b) { return a - b; });
        self.postMessage({ action: 'sort', result: sorted });
    } else if (action === 'statistics') {
        const sum = data.reduce(function(acc, val) { return acc + val; }, 0);
        const mean = sum / data.length;
        const sortedData = data.slice().sort(function(a, b) { return a - b; });
        const mid = Math.floor(sortedData.length / 2);
        const median = sortedData.length % 2 !== 0
            ? sortedData[mid]
            : (sortedData[mid - 1] + sortedData[mid]) / 2;
        self.postMessage({ action: 'statistics', result: { mean: mean, median: median } });
    }
};
Pro Tip: Always include an action or type field in your messages so both the worker and main thread can identify what kind of task or response they are dealing with. This pattern scales well when your worker handles multiple types of operations.

Transferable Objects for High Performance

The structured cloning process copies data when sending messages. For small data this is fast, but for large data like big ArrayBuffer objects (such as image data or audio buffers), copying can be expensive and slow. Transferable objects solve this problem by transferring ownership of the memory from one thread to another instead of copying it. The transfer is nearly instantaneous regardless of the data size, but after transferring, the original thread can no longer access that data.

Example: Transferring an ArrayBuffer

// main.js -- Transferring a large buffer to the worker
const largeBuffer = new ArrayBuffer(1024 * 1024 * 50); // 50 MB buffer
const uint8View = new Uint8Array(largeBuffer);

// Fill with sample data
for (let i = 0; i < uint8View.length; i++) {
    uint8View[i] = i % 256;
}

console.log('Buffer size before transfer:', largeBuffer.byteLength); // 52428800

// Transfer the buffer -- second argument is the list of transferable objects
worker.postMessage({ buffer: largeBuffer }, [largeBuffer]);

console.log('Buffer size after transfer:', largeBuffer.byteLength); // 0 (transferred!)

// worker.js -- Receiving the transferred buffer
self.onmessage = function(event) {
    const buffer = event.data.buffer;
    const view = new Uint8Array(buffer);
    console.log('Received buffer of size:', buffer.byteLength);

    // Process the data...
    // Transfer it back when done
    self.postMessage({ buffer: buffer }, [buffer]);
};
Warning: After transferring an ArrayBuffer, the original variable becomes a zero-length buffer and is no longer usable. Attempting to read or write to it will not produce an error but the data will be empty. Plan your data flow carefully when using transferable objects.

Worker Limitations: What Workers Cannot Do

Web Workers operate in a restricted environment. Understanding what workers cannot access is just as important as knowing what they can do. Here is a comprehensive list of limitations:

  • No DOM access -- Workers cannot read or modify document, create elements, or change the page structure. All DOM updates must happen on the main thread.
  • No window object -- The window global is not available. Use self instead to reference the worker global scope.
  • No parent access -- Workers cannot access variables or functions defined in the main thread script directly.
  • Limited APIs -- Workers can use fetch, XMLHttpRequest, setTimeout, setInterval, IndexedDB, WebSockets, and importScripts, but not localStorage, sessionStorage, or any rendering APIs.
  • Same-origin policy -- The worker script file must be served from the same origin as the page, or loaded as a blob URL.

Example: What Works and What Fails in a Worker

// Inside worker.js

// These WORK in a worker:
self.fetch('https://api.example.com/data')
    .then(function(response) { return response.json(); })
    .then(function(data) { self.postMessage(data); });

setTimeout(function() {
    self.postMessage('Delayed message from worker');
}, 1000);

const url = new URL('https://example.com/path');
const now = Date.now();
const id = crypto.randomUUID();

// These FAIL in a worker (will throw ReferenceError):
// document.getElementById('test');   // No DOM
// window.alert('Hello');             // No window
// localStorage.setItem('key', 'v'); // No localStorage

SharedWorker: One Worker for Multiple Tabs

A regular Worker is tied to the page that created it. If you open multiple tabs of the same website, each tab creates its own worker. A SharedWorker solves this by allowing multiple browser tabs, iframes, or windows from the same origin to share a single worker instance. This is useful for tasks like maintaining a shared WebSocket connection, synchronizing state across tabs, or managing a shared cache.

Example: SharedWorker Communication

// main.js -- Connecting to a SharedWorker
const sharedWorker = new SharedWorker('shared-worker.js');

// SharedWorker uses a port for communication
sharedWorker.port.start();

sharedWorker.port.postMessage({ type: 'join', tabId: Date.now() });

sharedWorker.port.onmessage = function(event) {
    console.log('Message from shared worker:', event.data);
};

// shared-worker.js
const connectedPorts = [];

self.onconnect = function(event) {
    const port = event.ports[0];
    connectedPorts.push(port);

    port.onmessage = function(msgEvent) {
        const data = msgEvent.data;
        if (data.type === 'join') {
            // Notify all connected tabs about the new connection
            connectedPorts.forEach(function(p) {
                p.postMessage({
                    type: 'update',
                    connectedTabs: connectedPorts.length
                });
            });
        }
    };

    port.start();
};
Note: SharedWorkers communicate through MessagePort objects instead of directly through postMessage on the worker itself. Each connecting context (tab, iframe) receives its own port. You must call port.start() to begin receiving messages.

Real-World Worker Use Cases

Web Workers shine in scenarios involving heavy computation or data processing. Here are practical use cases where workers dramatically improve user experience:

Heavy Computation: Prime Number Generation

Example: Computing Primes in a Worker

// prime-worker.js
self.onmessage = function(event) {
    const limit = event.data.limit;
    const primes = [];

    for (let num = 2; num <= limit; num++) {
        let isPrime = true;
        for (let i = 2; i <= Math.sqrt(num); i++) {
            if (num % i === 0) {
                isPrime = false;
                break;
            }
        }
        if (isPrime) {
            primes.push(num);
        }

        // Report progress every 10000 numbers
        if (num % 10000 === 0) {
            self.postMessage({
                type: 'progress',
                checked: num,
                total: limit,
                found: primes.length
            });
        }
    }

    self.postMessage({ type: 'complete', primes: primes });
};

// main.js
const primeWorker = new Worker('prime-worker.js');
primeWorker.postMessage({ limit: 1000000 });

primeWorker.onmessage = function(event) {
    if (event.data.type === 'progress') {
        const percent = Math.round((event.data.checked / event.data.total) * 100);
        document.getElementById('progress').textContent = percent + '% complete';
    } else if (event.data.type === 'complete') {
        document.getElementById('result').textContent =
            'Found ' + event.data.primes.length + ' primes';
    }
};

Data Processing: CSV Parsing

Example: Parsing Large CSV Data in a Worker

// csv-worker.js
self.onmessage = function(event) {
    const csvText = event.data.csv;
    const lines = csvText.split('\n');
    const headers = lines[0].split(',').map(function(h) { return h.trim(); });
    const records = [];

    for (let i = 1; i < lines.length; i++) {
        if (lines[i].trim() === '') continue;
        const values = lines[i].split(',');
        const record = {};
        headers.forEach(function(header, index) {
            record[header] = values[index] ? values[index].trim() : '';
        });
        records.push(record);
    }

    self.postMessage({
        headers: headers,
        records: records,
        totalRows: records.length
    });
};

Image Manipulation: Applying Filters

Example: Grayscale Filter in a Worker

// image-worker.js
self.onmessage = function(event) {
    const imageData = event.data.imageData;
    const pixels = imageData.data;

    for (let i = 0; i < pixels.length; i += 4) {
        const red = pixels[i];
        const green = pixels[i + 1];
        const blue = pixels[i + 2];
        // Luminance formula for perceptual grayscale
        const gray = Math.round(0.299 * red + 0.587 * green + 0.114 * blue);
        pixels[i] = gray;       // Red
        pixels[i + 1] = gray;   // Green
        pixels[i + 2] = gray;   // Blue
        // pixels[i + 3] is alpha, leave unchanged
    }

    self.postMessage({ imageData: imageData }, [imageData.data.buffer]);
};

// main.js -- Extracting image data and sending to worker
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

const imageWorker = new Worker('image-worker.js');
imageWorker.postMessage(
    { imageData: imageData },
    [imageData.data.buffer]
);

imageWorker.onmessage = function(event) {
    ctx.putImageData(event.data.imageData, 0, 0);
};

Performance Measurement with performance.now()

Before you can optimize performance, you need to measure it accurately. The performance.now() method returns a high-resolution timestamp in milliseconds, accurate to microseconds. Unlike Date.now(), it uses a monotonic clock that is not affected by system clock changes and starts from the page navigation time rather than the Unix epoch.

Example: Precise Timing with performance.now()

// Measure how long a function takes to execute
const startTime = performance.now();

// Simulate an expensive operation
let sum = 0;
for (let i = 0; i < 10000000; i++) {
    sum += Math.sqrt(i);
}

const endTime = performance.now();
const duration = endTime - startTime;

console.log('Operation took ' + duration.toFixed(2) + ' milliseconds');
console.log('Result: ' + sum);

// Compare two approaches
function measureFunction(fn, label) {
    const start = performance.now();
    const result = fn();
    const end = performance.now();
    console.log(label + ': ' + (end - start).toFixed(2) + 'ms');
    return result;
}

measureFunction(function() {
    return Array.from({ length: 100000 }, function(_, i) { return i * 2; });
}, 'Array.from');

measureFunction(function() {
    const arr = [];
    for (let i = 0; i < 100000; i++) {
        arr.push(i * 2);
    }
    return arr;
}, 'Manual push');

The Performance API and PerformanceObserver

The Performance API provides a comprehensive toolkit for measuring various aspects of page performance. You can create custom performance marks and measures, observe long tasks, and analyze navigation and resource loading timings.

Example: Performance Marks and Measures

// Create marks at specific points in your code
performance.mark('data-fetch-start');

fetch('https://api.example.com/users')
    .then(function(response) { return response.json(); })
    .then(function(data) {
        performance.mark('data-fetch-end');

        // Create a measure between two marks
        performance.measure('data-fetch-duration', 'data-fetch-start', 'data-fetch-end');

        // Retrieve the measurement
        const measures = performance.getEntriesByName('data-fetch-duration');
        console.log('Fetch took:', measures[0].duration.toFixed(2), 'ms');

        performance.mark('render-start');
        renderUserList(data);
        performance.mark('render-end');

        performance.measure('render-duration', 'render-start', 'render-end');
        const renderMeasure = performance.getEntriesByName('render-duration');
        console.log('Rendering took:', renderMeasure[0].duration.toFixed(2), 'ms');
    });

// Use PerformanceObserver to watch for long tasks (tasks > 50ms)
const observer = new PerformanceObserver(function(list) {
    list.getEntries().forEach(function(entry) {
        console.warn('Long task detected!');
        console.warn('Duration:', entry.duration.toFixed(2), 'ms');
        console.warn('Start time:', entry.startTime.toFixed(2), 'ms');
    });
});

observer.observe({ entryTypes: ['longtask'] });

Using console.time for Quick Benchmarks

For quick and informal performance measurements during development, console.time() and console.timeEnd() provide a convenient shorthand. They automatically log the elapsed time with a label to the console.

Example: console.time for Quick Measurements

// Simple timing
console.time('array-sort');
const bigArray = Array.from({ length: 500000 }, function() {
    return Math.random();
});
bigArray.sort();
console.timeEnd('array-sort'); // Logs: array-sort: 142.35ms

// Nested timers with different labels
console.time('total-operation');

console.time('step-1-generate');
const data = Array.from({ length: 100000 }, function(_, i) {
    return { id: i, value: Math.random() * 1000 };
});
console.timeEnd('step-1-generate');

console.time('step-2-filter');
const filtered = data.filter(function(item) {
    return item.value > 500;
});
console.timeEnd('step-2-filter');

console.time('step-3-transform');
const transformed = filtered.map(function(item) {
    return { id: item.id, doubled: item.value * 2 };
});
console.timeEnd('step-3-transform');

console.timeEnd('total-operation');

// Using console.timeLog to check intermediate values
console.time('long-process');
for (let i = 0; i < 5; i++) {
    // Simulate work
    const arr = new Array(100000).fill(0).map(Math.random);
    arr.sort();
    console.timeLog('long-process', 'Completed iteration ' + i);
}
console.timeEnd('long-process');

Long Tasks and Jank

The browser aims to render frames at 60 frames per second, which gives each frame a budget of approximately 16.67 milliseconds. Within that time, the browser must run JavaScript, calculate styles, perform layout, paint, and composite layers. When a JavaScript task takes longer than 50 milliseconds, it is classified as a long task, and it will almost certainly cause jank -- visible stuttering or freezing in animations, scrolling, or user interactions.

Common causes of long tasks include: processing large datasets synchronously, complex DOM manipulation in a loop, expensive regular expressions on large strings, synchronous layout reads followed by writes (layout thrashing), and parsing or serializing large JSON objects.

Example: Breaking Up Long Tasks with Yielding

// BAD: Processing all items at once blocks the main thread
function processAllAtOnce(items) {
    items.forEach(function(item) {
        expensiveCalculation(item);
    });
}

// GOOD: Yield to the main thread between chunks
function processInChunks(items, chunkSize) {
    let index = 0;

    function processChunk() {
        const end = Math.min(index + chunkSize, items.length);

        while (index < end) {
            expensiveCalculation(items[index]);
            index++;
        }

        if (index < items.length) {
            // Yield to the browser, then continue with the next chunk
            setTimeout(processChunk, 0);
        } else {
            console.log('All items processed!');
        }
    }

    processChunk();
}

// BETTER: Use requestIdleCallback for non-urgent work
function processWhenIdle(items) {
    let index = 0;

    function processNext(deadline) {
        // Process items while there is idle time remaining
        while (index < items.length && deadline.timeRemaining() > 5) {
            expensiveCalculation(items[index]);
            index++;
        }

        if (index < items.length) {
            requestIdleCallback(processNext);
        } else {
            console.log('All items processed during idle time!');
        }
    }

    requestIdleCallback(processNext);
}
Warning: Never assume that setTimeout(fn, 0) runs immediately. It merely queues the callback to run after the current call stack clears and the browser has had a chance to handle pending events and rendering. The actual delay is typically 4 milliseconds or more.

Code Splitting Strategies

Code splitting is the practice of breaking your JavaScript bundle into smaller chunks that are loaded on demand rather than all at once. When a user visits your page, they should only download the code necessary for the current view. Additional code is loaded in the background or when the user navigates to a section that requires it.

Example: Dynamic Import for Code Splitting

// Instead of importing everything at the top of the file:
// import { heavyChartLibrary } from './charts.js';

// Load the chart library only when the user clicks the button
document.getElementById('show-chart-btn').addEventListener('click', function() {
    // Show a loading indicator
    document.getElementById('chart-container').textContent = 'Loading chart...';

    // Dynamic import returns a Promise
    import('./charts.js')
        .then(function(module) {
            // Module is now loaded; use it
            module.renderChart('chart-container', chartData);
        })
        .catch(function(error) {
            console.error('Failed to load chart module:', error);
            document.getElementById('chart-container').textContent =
                'Error loading chart. Please try again.';
        });
});

// Route-based code splitting example
function navigateTo(route) {
    if (route === '/dashboard') {
        import('./pages/dashboard.js').then(function(module) {
            module.init();
        });
    } else if (route === '/settings') {
        import('./pages/settings.js').then(function(module) {
            module.init();
        });
    } else if (route === '/reports') {
        import('./pages/reports.js').then(function(module) {
            module.init();
        });
    }
}

Lazy Loading

Lazy loading is a strategy where resources are loaded only when they are needed, rather than upfront. This applies to images, scripts, components, and even data. The most common form of lazy loading in modern browsers is the native loading="lazy" attribute for images and iframes, but you can implement lazy loading for any resource using the Intersection Observer API.

Example: Lazy Loading Images with Intersection Observer

// HTML: <img data-src="photo.jpg" alt="Description" class="lazy-img">

function lazyLoadImages() {
    const lazyImages = document.querySelectorAll('.lazy-img');

    const imageObserver = new IntersectionObserver(function(entries, observer) {
        entries.forEach(function(entry) {
            if (entry.isIntersecting) {
                const img = entry.target;
                // Replace data-src with actual src to trigger loading
                img.src = img.dataset.src;

                img.addEventListener('load', function() {
                    img.classList.add('loaded');
                });

                img.addEventListener('error', function() {
                    img.src = 'placeholder.jpg';
                    img.alt = 'Image failed to load';
                });

                // Stop observing this image since it has been loaded
                observer.unobserve(img);
            }
        });
    }, {
        rootMargin: '100px 0px', // Start loading 100px before visible
        threshold: 0.01
    });

    lazyImages.forEach(function(img) {
        imageObserver.observe(img);
    });
}

// Initialize lazy loading when the DOM is ready
document.addEventListener('DOMContentLoaded', lazyLoadImages);
Pro Tip: Set a rootMargin value like '100px 0px' on your Intersection Observer so images start loading slightly before they scroll into view. This creates a smoother experience because the image will often be fully loaded by the time the user scrolls to it.

Virtual Scrolling Concept

When you have a list of thousands or tens of thousands of items, rendering all of them to the DOM at once is extremely expensive. Virtual scrolling (also called windowed rendering) solves this by only rendering the items currently visible in the viewport, plus a small buffer above and below. As the user scrolls, items that leave the viewport are removed from the DOM, and new items entering the viewport are added. This keeps the DOM node count small and constant regardless of the total list size.

Example: Basic Virtual Scrolling Implementation

function createVirtualList(container, items, itemHeight) {
    const visibleCount = Math.ceil(container.clientHeight / itemHeight);
    const bufferCount = 5; // Extra items above and below
    const totalHeight = items.length * itemHeight;

    // Create a tall spacer to maintain scroll height
    const spacer = document.createElement('div');
    spacer.style.height = totalHeight + 'px';
    spacer.style.position = 'relative';
    container.appendChild(spacer);

    // Content container for visible items
    const content = document.createElement('div');
    content.style.position = 'absolute';
    content.style.width = '100%';
    spacer.appendChild(content);

    function renderVisibleItems() {
        const scrollTop = container.scrollTop;
        const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferCount);
        const endIndex = Math.min(
            items.length,
            startIndex + visibleCount + bufferCount * 2
        );

        // Position the content container
        content.style.top = (startIndex * itemHeight) + 'px';

        // Clear and re-render only visible items
        content.innerHTML = '';
        for (let i = startIndex; i < endIndex; i++) {
            const itemElement = document.createElement('div');
            itemElement.style.height = itemHeight + 'px';
            itemElement.style.boxSizing = 'border-box';
            itemElement.textContent = items[i];
            content.appendChild(itemElement);
        }
    }

    container.addEventListener('scroll', renderVisibleItems);
    renderVisibleItems(); // Initial render
}

// Usage: render 100,000 items efficiently
const container = document.getElementById('list-container');
const items = Array.from({ length: 100000 }, function(_, i) {
    return 'Item #' + (i + 1);
});
createVirtualList(container, items, 40);

Terminating Workers

When you no longer need a worker, it is important to terminate it to free up system resources. You can terminate a worker from the main thread using worker.terminate(), or the worker can close itself using self.close(). Terminated workers cannot be restarted -- you must create a new Worker instance if you need one again.

Example: Worker Lifecycle Management

// Create a worker for a specific task
const worker = new Worker('task-worker.js');

worker.postMessage({ data: largeDataSet });

worker.onmessage = function(event) {
    if (event.data.type === 'complete') {
        console.log('Task finished, terminating worker');
        worker.terminate(); // Free resources
    }
};

// Set a timeout to terminate if the task takes too long
const timeoutId = setTimeout(function() {
    console.warn('Worker timed out, terminating');
    worker.terminate();
}, 30000);

// Inside worker.js -- self-terminating after task
self.onmessage = function(event) {
    const result = processData(event.data);
    self.postMessage({ type: 'complete', result: result });
    self.close(); // Worker terminates itself
};

Practice Exercise

Build a web application that demonstrates Web Workers and performance optimization. Create a page with two buttons: one that computes the sum of all prime numbers up to 5 million on the main thread, and another that performs the same computation using a Web Worker. Add a spinning CSS animation on the page. When you click the main-thread button, observe how the animation freezes. When you click the worker button, the animation should remain smooth. Display the computation time using performance.now() for both approaches. Add a progress bar that updates in real time as the worker processes chunks of numbers. Then implement a virtual scrolling list below the buttons that displays 50,000 items, each showing an item number and a random color swatch. Finally, add a third button that lazy-loads a heavy JavaScript module using dynamic import() and logs the loading time to the console.