How JavaScript Runs in a Web Browser

How JavaScript Runs in a Web Browser

You type a URL and press Enter. A fraction of a second later, a page appears, its scripts have run, and a button responds to your click. Somewhere in between, a browser did a remarkable amount of work: it fetched files over the network, parsed HTML into a tree, turned CSS into another tree, combined them, calculated the position of every box on the page, painted pixels, and ran every line of JavaScript you wrote.

Most of that happens on one thread. The same thread that runs your for loop also handles layout and painting, which is why a slow script freezes the whole page. Understanding that arrangement explains almost everything about browser performance.

This guide walks through the whole journey: the browser’s process model, the parser and how scripts interrupt it, the difference between blocking, async, deferred, and module scripts, the rendering pipeline, and how Web Workers finally give JavaScript a second thread.

In one sentence: A browser parses HTML into the DOM and CSS into the CSSOM, runs JavaScript on the same main thread that handles layout and painting, and produces pixels through a pipeline of style, layout, paint, and composite.

The Browser Is Not One Program

The first thing to understand is that a modern browser is not a single process. It is several, and the separation matters because it explains why one crashed tab does not take down the whole browser, and why some parts of the page can be busy while others stay responsive.

Browser process

The main controller. Manages the address bar, bookmarks, tabs, and the overall UI. There is only one.

Renderer process

One per tab (roughly). Contains the HTML parser, the CSS engine, the JavaScript engine, and the layout and paint code.

GPU process

Handles everything that goes to the screen — compositing layers, drawing, and hardware-accelerated animation.

Network process

Handles all HTTP requests, DNS lookups, TLS negotiation, and caching. Shared across tabs.

Plugin process

Runs plugins and embedded content in isolation, so a misbehaving plugin cannot crash the browser.

Utility processes

Small helper processes for storage, audio, printing, and other background services.

The renderer process is the one that matters for JavaScript. It is where everything in this article happens: parsing, execution, layout, and painting all live here.

Inside the renderer: the threads

  • The main thread Parses HTML, runs JavaScript, calculates styles, performs layout, and coordinates painting. One thread, doing all of that, in sequence.
  • Web Worker threads Optional background threads you create yourself. They run JavaScript in parallel with the main thread but cannot touch the DOM.
  • Compositor and raster threads Take the layers the main thread produces and turn them into pixels on the GPU. They can keep animating even when the main thread is busy.
  • Network and I/O threads Fetch files and return the results to the main thread as callbacks.
  • Why “one main thread” matters

    JavaScript, HTML parsing, style calculation, layout, and painting all share the same thread. A script that takes 200ms stops everything else for 200ms. That is the single most important fact in this article.

    The Journey of a Page Load

    Here is the whole sequence, in order, from pressing Enter to seeing pixels.

    Navigation The browser process resolves DNS, opens a TCP connection, negotiates TLS, and sends the request. When the first bytes of HTML arrive, it hands them to a renderer process.
    Parsing HTML The HTML parser reads the byte stream, converts it to characters, tokenizes it, and builds the DOM tree one node at a time. It works top to bottom.
    Encountering external resources When the parser hits a <link> for CSS or a <script>, it may have to pause while those files are fetched and processed. This is where the blocking behaviour comes from.
    Building the CSSOM Stylesheets are parsed into a tree of rules. CSS is render-blocking — the browser will not paint until it knows how everything should look.
    Running JavaScript Each script is compiled and executed. Scripts can read and modify the DOM, read and write styles, and register event handlers.
    Building the render tree The DOM and CSSOM are combined into a render tree containing only the elements that will actually be displayed, each with its computed styles.
    Layout The browser calculates the exact position and size of every box, in device-independent pixels. This is also called reflow.
    Paint The browser fills in the pixels for each box — backgrounds, borders, text, shadows, images.
    Composite Layers are assembled on the GPU and drawn to the screen. Some layers can be composited independently, which lets them animate without re-running the whole pipeline.

    JavaScript can interrupt any of these stages and force earlier ones to run again. Changing the DOM invalidates the render tree; changing a style invalidates layout; changing a color may only invalidate paint. That is the mechanism behind “layout thrashing,” covered later.

    Parsing HTML and Building the DOM

    The HTML parser is a streaming machine. It does not wait for the whole file to arrive — it processes bytes as they come in, building the DOM tree incrementally.

    Bytes
    →
    Characters
    →
    Tokens
    →
    Nodes
    →
    DOM tree

    At each stage it produces something the next stage can consume. When it reaches a <script> tag, everything changes.

    The blocking script

    By default, a <script> element does two blocking things at once. First, the parser stops and waits for the file to download. Then the parser stops again while the script runs.

    <body>
        <h1>Hello</h1>
    
        <!-- The parser stops here: download, then run, then resume -->
        <script src="app.js"></script>
    
        <p>This paragraph is only parsed after app.js has finished.</p>
    </body>

    That is why the classic advice is to put scripts at the end of the body. By then, the rest of the HTML has already been parsed, so the script can safely query any element on the page.

    A blocking script in the head delays everything

    If you place a large blocking script in the <head>, the parser stops before it has parsed a single element of the body. The user stares at a blank page while the file downloads. This is one of the most common causes of a slow-feeling site.

    Blocking, async, defer, and Modules

    The script element has two attributes that change its behaviour, plus a type="module" mode that changes it again. The differences are about two things: whether parsing pauses, and when the script runs.

    Blocking

    <script src=”app.js”></script>

    Parsing stops while the file downloads, then stops again while it runs.

    Order is guaranteed. Nothing else happens in between.

    Async

    <script async src=”app.js”></script>

    Downloads in parallel with parsing. Runs as soon as it finishes — which may be before parsing is done.

    Order is not guaranteed. Best for independent scripts.

    Defer

    <script defer src=”app.js”></script>

    Downloads in parallel. Runs after the HTML is fully parsed, in document order.

    The safest choice for most scripts.

    How each script type behaves
    Type Pauses parsing? When it runs Order preserved?
    Plain <script> Yes, twice Immediately, at its position Yes
    async No Whenever it finishes downloading No
    defer No After HTML parsing completes Yes
    type="module" No After parsing, like defer Yes

    See the difference on a timeline

    These bars show what the main thread is doing over time for three different script setups.

    Blocking
    parse
    download
    run
    parse rest
    async
    parse
    parse continues…
    run
    parse
    defer
    parse
    parse continues in parallel with download…
    run

    In the blocking row, parsing genuinely stops. In the async row, parsing continues while the file downloads, and the script interrupts whenever it is ready. In the defer row, parsing runs to completion and the script runs afterwards, with nothing left to interrupt.

    When to use which

    • defer — the default for your own application scripts. It never blocks, and the order is guaranteed.
    • async — for scripts that are completely independent: analytics, ads, third-party widgets. Nothing else depends on them.
    • plain — rarely needed today. Sometimes used for a tiny inline script that must run before anything else, such as an anti-flash theme switcher.
    • type=”module” — for modern applications. Deferred by default, scoped, strict, and it supports import.
    The practical default

    If you are unsure, use defer. It gives you parallel downloads, guaranteed order, and a fully parsed DOM when the script runs. There is very little reason to use a plain blocking script in new code.

    ES Modules in the Browser

    Modules changed how JavaScript is organized. A module is its own scope, it declares its dependencies explicitly with import, and the browser resolves the whole graph before running anything.

    // utils.js — a module that exports things
    export function formatPrice(value) {
        return '$' + value.toFixed(2);
    }
    
    export const TAX_RATE = 0.2;
    
    // app.js — a module that imports them
    import { formatPrice, TAX_RATE } from './utils.js';
    
    const total = formatPrice(99.99 * (1 + TAX_RATE));
    console.log(total);

    And in the HTML, the script tag needs the module type:

    <script type="module" src="app.js"></script>

    What modules change

    Benefits

    What you get

    Automatic defer — modules never block parsing.

    Own scope — top-level variables do not leak to window.

    Strict mode by default — silent bugs become errors.

    Explicit dependencies — you can see what a file needs by reading its imports.

    Loaded once — a module imported by three files still executes only once.

    Constraints

    What to watch for

    Must be served over HTTP, not opened from file:// — CORS blocks it.

    Imports must include the file extension — ./utils.js, not ./utils.

    Each module is a separate network request unless you bundle them.

    Older browsers need a fallback with nomodule.

    The import graph is a waterfall

    Modules are discovered by following imports. The browser fetches app.js, parses it, discovers it imports utils.js, fetches that, discovers its imports, and so on. Deeply nested modules can create a slow chain of requests, which is why production builds usually bundle everything into one file.

    CSS Is Render-Blocking

    Stylesheets block rendering, and it is worth understanding why. The browser will not paint anything until it has the full CSSOM, because painting with incomplete styles would mean painting the page twice.

    What would happen without blocking
    <link rel="stylesheet" href="main.css"> <!-- Browser paints the page unstyled --> <!-- Then main.css arrives --> <!-- Browser paints it again, styled --> <!-- The user sees a flash of unstyled content. -->
    What actually happens
    <link rel="stylesheet" href="main.css"> <!-- Browser waits for the CSS --> <!-- Builds the CSSOM --> <!-- Then paints once, correctly --> <!-- One render, no flash. -->

    CSS also blocks scripts

    There is a subtlety here that catches people out. A script that follows a stylesheet will wait for that stylesheet to load before running, even if the script is not blocked by parsing. The reason is that a script might query a computed style, and the browser cannot answer that question without the CSSOM.

    <head>
        <!-- This stylesheet blocks the script below -->
        <link rel="stylesheet" href="theme.css">
    
        <!-- The browser waits for theme.css before running this,
             even though the script tag itself does not block parsing -->
        <script>
            const color = getComputedStyle(document.body).color;
        </script>
    </head>
    A slow stylesheet delays your scripts

    A stylesheet hosted on a slow third-party CDN can hold up every script that comes after it. If scripts are running later than expected, check the network waterfall — CSS is a common culprit.

    The Main Thread at Work

    Once the page has loaded, the main thread settles into a loop. It runs tasks from the task queue, drains microtasks between them, and periodically renders a frame.

    Run a task
    →
    Drain microtasks
    →
    Maybe render
    →
    Next task

    Rendering does not happen after every task. The browser tries to render about sixty times per second, and if the main thread is busy it simply skips frames. That is what “the page feels janky” means.

    The 16 millisecond budget

    Sixty frames per second means each frame has about 16.7 milliseconds. Within that budget the browser has to run your event handlers, recalculate styles, lay out, and paint. If your JavaScript alone takes 20ms, you have already missed the frame.

    The main thread is shared

    This example shows what happens when a synchronous script occupies the main thread. The spinner animates smoothly — until the blocking code runs, and then it stops dead.

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Blocking the Main Thread</title>
        <style>
            body {
                font-family: system-ui, sans-serif;
                padding: 32px;
                background: #f8fafc;
                margin: 0;
                text-align: center;
            }
            .spinner {
                width: 60px;
                height: 60px;
                margin: 0 auto 20px;
                border: 6px solid #e2e8f0;
                border-top-color: #0891b2;
                border-radius: 50%;
                animation: spin 1s linear infinite;
            }
            @keyframes spin {
                to { transform: rotate(360deg); }
            }
            button {
                padding: 12px 24px;
                border: 0;
                border-radius: 10px;
                background: #0891b2;
                color: #fff;
                font: inherit;
                font-weight: 600;
                cursor: pointer;
            }
            button:hover { background: #0e7490; }
            .status {
                margin-top: 18px;
                font-size: 14px;
                color: #64748b;
                font-family: ui-monospace, monospace;
            }
        </style>
    </head>
    <body>
    
        <div class="spinner"></div>
    
        <button id="block">Block the main thread for 2s</button>
    
        <p class="status" id="status">The spinner is animating smoothly.</p>
    
        <script>
            const btn    = document.getElementById('block');
            const status = document.getElementById('status');
    
            btn.addEventListener('click', function () {
                status.textContent = 'Blocking now — the spinner will freeze…';
    
                // Give the browser one frame to update the status text
                requestAnimationFrame(function () {
    
                    // Synchronous, blocking work
                    const start = Date.now();
                    while (Date.now() - start < 2000) {
                        // spin — the main thread is fully occupied
                    }
    
                    status.textContent = 'Done. The spinner resumed.';
                });
            });
        </script>
    
    </body>
    </html>

    Click the button and watch the spinner. It stops completely for two seconds, along with everything else on the page. Then it resumes. That is the main thread being monopolized.

    Use the Performance panel to see this

    Open DevTools, switch to the Performance tab, and record while the button is clicked. You will see a long solid task with no frames rendered in between — the visual signature of a blocked main thread.

    How JavaScript Triggers Rendering

    When JavaScript changes the DOM or styles, the browser does not re-render immediately. It marks the relevant parts as dirty and re-runs the pipeline before the next frame.

    Style Recalculate which CSS rules apply to which elements. Triggered by adding or removing a class, changing a stylesheet, or adding elements.
    Layout Recalculate the position and size of every affected box. Triggered by changing width, height, margin, padding, font-size, or the DOM structure.
    Paint Fill in the pixels. Triggered by changing color, background, border, or shadow. Cheaper than layout.
    Composite Assemble the layers on the GPU. Triggered by changing transform or opacity. Cheapest of all — can run on the compositor thread without the main thread.
    What each kind of change costs
    Change Pipeline stages re-run Cost
    Adding an element Style → Layout → Paint → Composite Expensive
    width, margin, font-size Style → Layout → Paint → Composite Expensive
    color, background, box-shadow Style → Paint → Composite Moderate
    transform, opacity Composite only Cheap
    Animate transform and opacity

    Because transform and opacity can be handled by the compositor alone, animating them keeps the main thread free. Animating left, width, or margin forces layout on every frame and is the most common cause of janky animation.

    Layout Thrashing

    There is a trap that catches almost everyone once. Reading certain properties from the DOM forces the browser to run layout immediately, because it has to give you an accurate answer. If you write, then read, then write, then read in a loop, you force layout on every iteration.

    Incorrect — forces layout every iteration
    const boxes = document.querySelectorAll('.box'); boxes.forEach(function (box) { // Write box.style.width = box.offsetWidth + 10 + 'px'; // Read — forces layout const h = box.offsetHeight; // Write again box.style.height = h + 10 + 'px'; }); // 100 boxes = 200 forced layouts.
    Correct — batch reads, then writes
    const boxes = document.querySelectorAll('.box'); // Read everything first const sizes = Array.from(boxes).map(function (box) { return { w: box.offsetWidth, h: box.offsetHeight }; }); // Then write everything boxes.forEach(function (box, i) { box.style.width = sizes[i].w + 10 + 'px'; box.style.height = sizes[i].h + 10 + 'px'; }); // One layout total.

    Which properties force a layout

    These reads all cause the browser to flush pending changes and recalculate layout:

    • offsetWidth, offsetHeight, offsetTop, offsetLeft
    • clientWidth, clientHeight, clientTop, clientLeft
    • scrollWidth, scrollHeight, scrollTop, scrollLeft
    • getBoundingClientRect()
    • getComputedStyle() — for layout-dependent properties
    The rule

    Never mix reads and writes in the same loop. Read everything you need first, then perform all the writes. The browser will batch them into a single layout.

    The Network Waterfall

    The order in which the browser discovers and fetches resources determines how fast the page becomes usable. DevTools shows this as a waterfall, and reading one is a skill worth having.

    index.html
    HTML
    main.css
    CSS
    app.js
    JS
    utils.js
    JS
    hero.jpg
    IMG

    Notice that utils.js cannot start until app.js has arrived and been parsed, because that is when the browser discovers the import. That is the module waterfall. It is also why bundlers exist.

    What the waterfall tells you

    • Long bars at the start — your server or your HTML is slow.
    • Stair-stepped JavaScript — modules are loading one after another instead of in parallel.
    • A gap before rendering — something render-blocking is holding up the first paint.
    • Many small requests — consider bundling, or check whether you are loading resources you do not use.

    Web Workers: A Second Thread

    Everything so far has been about the limits of one thread. Web Workers are the escape hatch. A worker is a background thread that runs JavaScript in parallel with the main thread.

    Main thread

    • Owns the DOM
    • Handles events and rendering
    • Stays responsive
    • Sends and receives messages
    postMessage ↔ onmessage

    Worker thread

    • No DOM access
    • Runs heavy computation
    • Runs in parallel
    • Own global scope

    The trade-off is clear: a worker gets its own thread but loses access to the DOM. It cannot touch document or window, so it cannot update the page directly. It sends messages back to the main thread instead.

    A complete worker example

    The worker code lives in its own file. It listens for messages and posts results back:

    // worker.js — runs on its own thread
    
    self.onmessage = function (event) {
        const limit = event.data;
        let sum = 0;
    
        // Heavy work — this would freeze the page
        // if it ran on the main thread
        for (let i = 0; i < limit; i++) {
            sum += Math.sqrt(i);
        }
    
        self.postMessage(sum);
    };

    And the main thread creates it, sends work to it, and receives the result:

    // main.js — runs on the main thread
    
    const worker = new Worker('worker.js');
    
    worker.onmessage = function (event) {
        console.log('Result from worker:', event.data);
        // The page stayed responsive the whole time
    };
    
    worker.postMessage(50_000_000);
    Messages are copies, not shared references

    Data passed with postMessage is structured-cloned — copied, not shared. That means you cannot pass a DOM node, a function, or an object with circular references. For very large data, ArrayBuffer can be transferred rather than copied, which moves ownership without duplication.

    What workers are good for

    🔢

    Heavy computation

    Image processing, cryptography, statistics, parsing large files.

    📊

    Data crunching

    Sorting, filtering, or aggregating thousands of records without freezing the UI.

    🎨

    Offscreen canvas

    Drawing frames in a worker with OffscreenCanvas, keeping the main thread free.

    🌐

    Background fetching

    Polling or synchronizing data without interfering with user interaction.

    Starting a worker is not free

    Each worker is a new thread with its own memory and global scope. Spawning one costs a few milliseconds, and communication costs more. Use workers for genuinely heavy work, not for small tasks that would finish faster on the main thread anyway.

    The Page Lifecycle

    A page is not simply loaded or unloaded. It moves through several states, and JavaScript can respond to each one.

    DOMContentLoaded
    1
    HTML is parsed and the DOM is ready Fires on document. Images and stylesheets may still be loading. This is when most scripts should run.
    load
    2
    Every resource has finished loading Fires on window. Images, stylesheets, fonts, and iframes are all done. Often later than people expect.
    beforeunload
    3
    The user is about to leave Can show a confirmation dialog. Use sparingly — it annoys users and browsers increasingly ignore it.
    visibilitychange
    4
    The tab was hidden or shown The right place to pause timers, stop animations, and save state. Fires when the user switches tabs.
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Page Lifecycle</title>
        <style>
            body { font-family: system-ui, sans-serif; padding: 24px; background: #f8fafc; font-size: 14px; }
            .line {
                background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
                padding: 10px 14px; margin-bottom: 6px;
                font-family: ui-monospace, monospace; font-size: 12.5px;
                display: flex; justify-content: space-between; gap: 12px;
            }
            .time { color: #64748b; font-size: 11px; }
            .hint { color: #64748b; margin-top: 16px; font-size: 13px; line-height: 1.6; }
        </style>
    </head>
    <body>
    
        <div id="output"></div>
    
        <p class="hint">
            Switch to another tab and come back — the visibilitychange event will fire
            both times.
        </p>
    
        <script>
            const out = document.getElementById('output');
            const start = performance.now();
    
            function log(name) {
                const elapsed = Math.round(performance.now() - start);
                const div = document.createElement('div');
                div.className = 'line';
                div.innerHTML =
                    '<span>' + name + '</span>' +
                    '<span class="time">+' + elapsed + 'ms</span>';
                out.appendChild(div);
            }
    
            log('script is running');
    
            document.addEventListener('DOMContentLoaded', function () {
                log('DOMContentLoaded — DOM is ready');
            });
    
            window.addEventListener('load', function () {
                log('load — all resources finished');
            });
    
            document.addEventListener('visibilitychange', function () {
                log('visibilitychange — hidden: ' + document.hidden);
            });
        </script>
    
    </body>
    </html>
    Use DOMContentLoaded, not load

    A script waiting for load has to wait for every image and font. A script waiting for DOMContentLoaded can run as soon as the HTML is parsed. In practice, a deferred script does not need either — it already runs after parsing.

    Debugging What the Browser Is Doing

    DevTools exposes every stage described in this article. Knowing which panel answers which question saves enormous time.

    Which DevTools panel answers which question
    Question Panel
    Did the file even load? Network — look for the request and its status code
    What order did things load in? Network — the waterfall view
    What is blocking the first paint? Performance — record a load and look for long tasks before render
    What is slow on the main thread? Performance — the flame chart shows every task and its duration
    Which CSS rule is actually applying? Elements → Styles — shows the cascade and computed values
    Why did layout run? Performance — enable “Layout Shift” and look at forced reflow warnings
    Is memory growing? Memory — take heap snapshots and compare
    Are my event handlers attached? Elements → Event Listeners
    The Coverage panel is underrated

    The Coverage tab tells you what percentage of your CSS and JavaScript actually ran. On a mature site it is common to find that a large fraction of the JavaScript shipped is never executed. That is the fastest way to find something worth removing.

    Common Mistakes

    These are the patterns that most often make a page feel slow, and each one traces back to how the browser actually works.

    1. Blocking scripts in the head

    Incorrect — delays the first paint
    <head> <script src="app.js"></script> </head> <body> <h1>Content</h1> </body> <!-- The parser stops in the head. Nothing renders until app.js downloads and runs. -->
    Correct — defer it
    <head> <script src="app.js" defer></script> </head> <body> <h1>Content</h1> </body> <!-- Downloads in parallel. Runs after parsing. The page renders immediately. -->

    2. Reading layout properties in a loop

    Incorrect — forces layout every time
    items.forEach(function (item) { item.style.height = container.offsetHeight + 'px'; }); // Reading offsetHeight inside // the loop forces the browser // to recalculate layout each time.
    Correct — read once, outside the loop
    const h = container.offsetHeight; items.forEach(function (item) { item.style.height = h + 'px'; }); // One layout, one paint.

    3. Animating layout properties

    Incorrect — layout on every frame
    .box { transition: left 0.3s ease, width 0.3s ease; } // Animating left and width forces // style, layout, paint, and composite // on every single frame.
    Correct — composite only
    .box { transition: transform 0.3s ease, opacity 0.3s ease; } // transform and opacity can be // handled by the compositor alone. // The main thread stays free.

    4. Running heavy work on the main thread

    Incorrect — freezes the page
    function processAll(records) { return records.map(function (r) { return expensiveTransform(r); }); } // 50,000 records × 1ms each // = 50 seconds of frozen UI.
    Correct — move it to a worker
    const worker = new Worker('process.js'); worker.onmessage = function (e) { render(e.data); }; worker.postMessage(records); // The main thread stays // completely responsive.

    5. Using load instead of DOMContentLoaded

    Incorrect — waits for every image
    window.addEventListener('load', function () { init(); }); // If one image is slow, init() // waits for it. The user sees // an unresponsive page.
    Correct — run when the DOM is ready
    document.addEventListener( 'DOMContentLoaded', function () { init(); } ); // Or simply use a deferred script, // which already runs at that point.

    6. Loading modules without bundling

    Incorrect — a request per module
    <script type="module" src="app.js"> // app.js imports utils.js // utils.js imports helpers.js // helpers.js imports constants.js // Four sequential requests // before anything runs.
    Correct — bundle for production
    <script type="module" src="bundle.js"> // The build step combined all // four modules into one file. // One request, no waterfall.
    Measure before optimizing

    Every one of these fixes has a cost in complexity. Open the Performance panel, record a real load, and find where the time is actually going before rewriting anything. Most pages have one or two dominant problems, not ten.

    Best Practices

    These follow directly from how the browser works. Each one is a small change with an outsized effect.

    ⏳

    Defer your scripts

    Use defer or type="module" so parsing never pauses. Almost always the right default.

    📦

    Bundle for production

    Modules are great in development. Combine them at build time to avoid a request waterfall.

    🎨

    Animate transform and opacity

    They run on the compositor and leave the main thread free for everything else.

    📖

    Batch reads and writes

    Read all layout properties first, then write. Never interleave them in a loop.

    🧵

    Move heavy work to a worker

    Anything taking more than a few milliseconds belongs off the main thread.

    🖼️

    Lazy-load images

    loading="lazy" defers offscreen images so they do not compete with what is visible.

    📏

    Reserve space for images

    Set width and height so layout does not shift when the image arrives.

    ⚡

    Keep tasks under 50ms

    A task longer than 50ms is officially a “long task” and blocks interaction.

    🔍

    Profile with DevTools

    Record a real load. Fix the biggest problem first, then measure again.

    The one-line summary

    Everything shares one main thread. Defer your scripts, keep tasks short, move heavy work to a worker, and animate only transform and opacity.

    Frequently Asked Questions

    1. What happens when a browser loads a page?

    The browser fetches the HTML, parses it into a DOM tree, parses the CSS into a CSSOM, combines them into a render tree, calculates layout, and paints pixels. JavaScript runs on the main thread alongside all of that and can change any of the structures as it goes.

    2. What is the main thread?

    The main thread is the single thread where the browser parses HTML, runs JavaScript, calculates styles, performs layout, and paints the page. Because JavaScript shares it with rendering, long-running scripts block the page from updating.

    3. Does a script tag block HTML parsing?

    Yes, by default. A plain script element without async or defer pauses the HTML parser while the file downloads and then runs. That is why scripts are usually placed at the end of the body, or marked defer so they download in parallel and run after parsing.

    4. What is the difference between async and defer?

    Both download the script in parallel with parsing. defer waits until the HTML is fully parsed before running, and preserves the order of multiple scripts. async runs the script as soon as it finishes downloading, which means the order is not guaranteed.

    5. What is the difference between blocking, async, and deferred scripts?

    A blocking script pauses HTML parsing while it downloads and runs. An async script downloads in parallel and runs whenever it finishes, potentially interrupting parsing. A deferred script downloads in parallel and runs after parsing completes, in document order.

    6. Are ES modules deferred by default?

    Yes. A script with type="module" is deferred automatically. It downloads in parallel, runs after parsing, preserves the order of its dependencies, and runs in strict mode with its own scope.

    7. What is the DOM?

    The DOM — Document Object Model — is the browser’s in-memory tree of your HTML. Every element becomes a node object that JavaScript can read, change, add, or remove. Changes to the DOM are what cause the browser to re-render.

    8. What is the CSSOM?

    The CSSOM is the browser’s in-memory representation of all the CSS that applies to the page. It is combined with the DOM to produce the render tree, which the browser uses for layout and painting. JavaScript cannot read the CSSOM directly, but it can change it by modifying styles or class names.

    9. What is the render tree?

    The render tree is what the browser builds by combining the DOM and the CSSOM. It contains only the elements that will actually be displayed — elements with display: none are excluded — along with their computed styles.

    10. What are the steps in the rendering pipeline?

    The pipeline is: parse HTML and CSS, build the DOM and CSSOM, combine them into a render tree, calculate layout (positions and sizes), paint (fill in pixels), and composite layers to the screen. JavaScript can trigger any of these steps by changing the DOM or styles.

    11. What is a Web Worker?

    A Web Worker is a separate background thread that runs JavaScript in parallel with the main thread. Workers cannot touch the DOM directly, so they communicate with the main thread by posting messages. They are how you move heavy computation off the thread that renders the page.

    12. Why does a heavy script freeze the page?

    Because JavaScript runs on the same thread the browser uses for layout and painting. While a synchronous script is running, no events are handled and no frames are rendered. The page appears frozen until the script finishes and the main thread is free again.

    Key Takeaways

    • A browser is multiple processes; each tab has its own renderer.
    • The renderer’s main thread handles parsing, JavaScript, layout, and painting.
    • A plain <script> blocks parsing twice — for download and for execution.
    • async downloads in parallel but runs whenever it is ready, order not guaranteed.
    • defer downloads in parallel and runs after parsing, in order.
    • type="module" is deferred by default and has its own scope.
    • CSS is render-blocking, and it also blocks scripts that follow it.
    • The rendering pipeline is style, layout, paint, composite.
    • Animating transform and opacity skips layout and paint entirely.
    • Mixing layout reads and writes in a loop causes layout thrashing.
    • Web Workers run JavaScript on a second thread but cannot access the DOM.
    • If a task takes more than 50ms, it is officially blocking interaction.

    What to Learn Next

    Now that you know how the browser runs your code, the natural next step is the execution model underneath it. Read How JavaScript Works for the call stack, the event loop, and the queues.

    From there, explore Async and Await Explained, Working with the DOM, and JavaScript Events. When you are ready to measure and improve, read JavaScript Performance and Web Performance Fundamentals.

    If you are still connecting the pieces, read What Is JavaScript?, JavaScript vs TypeScript, and HTML vs CSS for the surrounding context.

    Practice Challenge

    The best way to see the main thread at work is to occupy it and watch what happens. This exercise takes about twenty-five minutes.

    1. Create index.html with a CSS spinner, a button, and a status paragraph. Load a deferred script.
    2. In the script, make the button run a synchronous loop for two seconds. Click it and watch the spinner freeze.
    3. Open DevTools, go to the Performance panel, and record while clicking. Find the long task on the flame chart and note its duration.
    4. Now change the script tag from defer to no attribute, and move it into the <head>. Reload and watch how much later the content appears.
    5. Add async instead. Reload a few times and note that the timing varies, because the script runs whenever it finishes downloading.
    6. Write a second script that also needs the DOM. With async on both, note that the order is not guaranteed — then switch both to defer and confirm it is.
    7. Add a module script that imports a second file. Watch the network waterfall and count the sequential requests.
    8. Create a list of 500 boxes and write a loop that reads offsetHeight and then writes style.height on each one. Record it in the Performance panel and look for forced reflow warnings.
    9. Rewrite the loop to read everything first, then write everything. Record again and compare the layout count.
    10. Change an animation from left to transform: translateX() and record both. Note the difference in paint and layout work.
    11. Finally, move the heavy loop into a Web Worker. Confirm the spinner keeps animating while the worker is busy, and that the result arrives by message.

    If you complete those steps, you have watched the main thread freeze and recover, seen the difference between blocking, async, and deferred loading in the network waterfall, triggered layout thrashing and fixed it, and moved real work onto a second thread. Those four things — how scripts load, what shares the main thread, how the rendering pipeline responds, and how to escape it with a worker — are the whole story of JavaScript in a browser.

    Inar Learn
    Inar Learnhttps://inarlearn.com
    Inar Learn is an innovative online learning platform offering high-quality courses, tutorials, and resources to help learners gain practical skills and grow their knowledge.

    Related Articles

    LEAVE A REPLY

    Please enter your comment!
    Please enter your name here