HTML Performance
A browser parses HTML top to bottom. Anything that stops the parser stops the page appearing. Most of what makes a page feel slow is decided in the first few lines of the head, which is also where it is cheapest to fix.
What the parser is doing
- Bytes arrive and are decoded into characters - using the
charsetyou declared. - Characters are tokenised into tags, and tags are built into the DOM tree.
- On meeting a
link rel="stylesheet", the browser fetches it and will not paint until it has arrived. Painting without CSS would flash unstyled content. - On meeting a classic
scriptwith no attributes, the parser stops, fetches it, runs it, and then resumes. A script could calldocument.write, so the parser cannot safely continue. - Images and iframes are fetched without blocking anything.
Points 3 and 4 are the two levers. Everything below is about them.
Script loading
<head>
<meta charset="utf-8">
<title>Script loading</title>
<script src="/blocking.js"></script>
<script src="/analytics.js" async></script>
<script src="/app.js" defer></script>
<script type="module" src="/main.js"></script>
</head>
<body>
<h1>Four scripts, four behaviours</h1>
</body>
Open this example in the editor and change it. Nothing you type is saved.
No attribute - the parser stops, downloads, executes, resumes. Every one of these in the head delays the first paint by a whole round trip.
async - downloads in parallel and executes as soon as it arrives, interrupting parsing at an unpredictable moment. Order between async scripts is not preserved. Right for something genuinely independent, such as an analytics beacon.
defer - downloads in parallel and executes after parsing finishes, in document order. This is the sensible default for application code: nothing blocks, and the script can rely on the whole document existing.
type="module" - deferred automatically, so a module script in the head does not block.
The old advice was to put scripts at the end of the body. defer is better: the download starts immediately rather than after the whole document has been parsed, and execution still waits.
Resource hints
<head>
<meta charset="utf-8">
<title>Resource hints</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.webp" as="image">
<link rel="prefetch" href="/courses" as="document">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<h1>Hints the browser may act on</h1>
</body>
Open this example in the editor and change it. Nothing you type is saved.
preconnect opens the connection to another origin early - DNS, TCP and TLS take hundreds of milliseconds, and doing them before anything needs the origin takes that off the critical path.
preload fetches something at high priority that the parser has not reached yet. Fonts are the standard case: a font is discovered only after the CSS has loaded and been applied, which is late.
prefetch fetches something at low priority for a later navigation.
Three warnings. Hints are advice, not commands. preload requires a correct as, and a wrong one fetches the file twice. And preloading everything prioritises nothing - a handful is the right number.
The crossorigin on the font preload is not optional: fonts are fetched in anonymous CORS mode, and a preload without it does not match the later request, so the file is downloaded twice.
Keeping the document small
Remove redundant markup. Deeply nested wrappers are nodes the browser must build, style and lay out. A tree three times deeper than it needs to be costs three times the work on every layout.
Avoid duplicating content. A mobile version and a desktop version of the same navigation, hidden alternately by CSS, is both in the DOM. One, laid out differently, is correct.
Inline only what is tiny. Inlining the critical CSS in a style block removes a round trip, and inlining anything substantial means it is re-downloaded with every page because it cannot be cached.
Compress. Gzip or Brotli on the server typically takes HTML to a fifth of its size, and it is a server setting rather than anything you write.
Stylesheets, and the one blocking resource worth keeping
A stylesheet blocks the first paint. That is correct behaviour - the alternative is a flash of unstyled text - so the goal is not to remove the block but to shorten it.
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Critical CSS</title>
<style>
body { margin: 0; font: 16px/1.6 system-ui, sans-serif; }
header { padding: 1rem; border-bottom: 1px solid #ddd; }
h1 { font-size: 1.6rem; margin: 1rem; }
</style>
<link rel="stylesheet" href="/styles.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>
<body>
<header><strong>Omni Academy</strong></header>
<h1>The page paints before the full stylesheet arrives</h1>
</body>
Open this example in the editor and change it. Nothing you type is saved.
The inline block holds just enough CSS to draw what is visible first. The full stylesheet is then loaded without blocking, by the old media="print" trick: a print stylesheet is not render-blocking, and swapping its media to all once it has loaded applies it. The noscript copy covers a reader with JavaScript off.
This is a real technique with a real cost: the inlined CSS is re-sent with every page because it cannot be cached. It is worth it for a landing page and usually not worth it for an application somebody keeps open.
Streaming, and why order matters
A browser does not wait for the whole document. It parses and paints as bytes arrive, which means markup near the top can be rendered while the rest is still coming.
Two consequences worth knowing. Content that matters should be early in the document - which is the same advice the responsive lesson gave for a different reason. And a slow server-side fragment in the middle of a page holds up everything after it, so an expensive widget belongs at the end of the markup, positioned wherever you like by CSS.
What to measure
Three numbers describe how a page feels:
Largest Contentful Paint - when the main content appeared. Usually decided by the hero image and by whatever blocks rendering.
Cumulative Layout Shift - how much the page moved while loading. Almost always missing width and height on images, or a web font swapping in at a different size.
Interaction to Next Paint - how long the page takes to respond to a tap. A JavaScript problem rather than a markup one.
Lighthouse, in every Chromium browser's developer tools, reports all three.
The order to work in is: measure first, fix the largest number, measure again. Performance work done by intuition reliably optimises the thing that was not the problem.
Practice task
Run Lighthouse on a page you have built and note the Largest Contentful Paint. Then make three changes: add defer to every script in the head, add width and height to every image, and add loading="lazy" to everything below the fold. Run it again. The layout-shift number should fall furthest, because it is the one with a purely markup-level cause.