Performance work goes wrong in a predictable way. Someone decides the site feels slow, changes three things, and has no way to tell whether any of them helped. The discipline is the opposite: pick a metric that describes something a person actually experiences, find out where the time currently goes, change the part that owns most of it, then look at the number again. This guide gives you the shape: which metrics exist and what each really counts, why lab and real-user scores disagree on purpose, and where the time hides on an ordinary page.
Each chapter opens with the short version. Tap one to read the detail.
What "fast" means, and why it is a percentile
~2 min
Three metrics describe the loading, responsiveness and visual stability a person actually feels. Each is judged at the 75th percentile of page loads, not the average — so a site that is fine for most visitors can still fail.
The headline set is three user-centric metrics. Largest Contentful Paint (LCP) times how long the biggest visible content takes to appear: good is 2.5 seconds or less, poor is over 4.0. Interaction to Next Paint (INP) times how quickly the page visibly answers a tap: good is 200 milliseconds or less, poor is over 500. Cumulative Layout Shift (CLS) scores how much content moves under the reader: good is 0.1 or less, poor is above 0.25. The middle band between good and poor is explicitly not a pass.
The mistake almost everyone makes is quoting an average. These are assessed at the 75th percentile of page loads, segmented by device type: order every load you served and take the one three-quarters of the way up.
Suppose your median load paints in 1.9 seconds and your 75th percentile paints in 4.3. The average says the site is quick and the metric says it is failing — and the metric is right, because a quarter of your visits are worse than 4.3 seconds. Percentiles are the honest unit because they refuse to average away the people who are suffering: older phones, congested networks, cold caches.
Not every timing is one of the three. Time to First Byte (TTFB) has its own bands — 0.8 seconds or less good, over 1.8 poor — but the source is explicit that it is a diagnostic explaining part of LCP, not a headline metric.
Rule: never quote an average — state the metric, the percentile and the device class, or the claim cannot be checked.
Measure first, then hold the line
~3 min
A scripted run and real-visitor data answer different questions, so a green lab score beside a failing field score is normal rather than contradictory. Measure before you change anything, then turn the number you care about into a budget the build can fail on.
Two kinds of performance number exist and they are not interchangeable. A lab number comes from one scripted load on a fixed device and connection; it is repeatable, which is why it can fail a build. A field number comes from real visitors on real hardware; it is noisy, and it is the only evidence anyone felt the change.
The misconception is that one should predict the other. It cannot. A scripted run never clicks anything — and a page nobody clicks, taps or types into produces no Interaction to Next Paint value at all, which is an absent score rather than a good one. A lab run also starts with an empty cache on hardware somebody chose, while real visitors arrive with warm caches, ad blockers and a two-year-old phone. Even one metric measured two ways can disagree: the Time to First Byte documentation notes that different technologies produce inconsistent results depending on what each includes.
In the browser, PerformanceObserver is notified of entries as they are recorded, so an observer created late in startup was not listening when the early ones happened; PerformanceObserver.supportedEntryTypes reports what this browser can produce at all. Suppose you beacon your metrics on the load event: Cumulative Layout Shift accrues across a page's entire lifecycle, so you have just reported a number that is still climbing.
Once you can measure, a budget stops the next regression, and the three shapes catch different things. Quantity budgets count weight, requests, scripts and fonts — easy to enforce and blind to experience, since two pages with the same request count can feel completely different depending on load order. Milestone budgets use user-centric moments such as First Contentful Paint (FCP). Rule-based budgets are a tool's score against a best-practice checklist: a guideline, not a substitute. Set the number from what comparable sites achieve rather than a satisfying round figure; the published fallbacks, for a mobile connection, are under five seconds to become interactive and under 170 kilobytes of compressed critical-path resources.
Then wire it in. A budget that fails the build stops a regression at the pull request; one in a document merely records it.
Rule: no optimization without a before-measurement, and no measurement without naming the population it describes.
Largest Contentful Paint: where the time actually goes
~2 min
LCP times the paint of the largest content element. It splits into four subparts, and the two meant to be tiny — discovery delay and render delay — are where a slow page usually sits. Blaming the framework is the reflex; the hero image is the culprit.
Only certain things can be the largest contentful element: an <img>, an <image> inside <svg>, a <video> poster or first frame, an element with a CSS url() background image, and a block-level element containing text.
The candidate is re-elected as content paints, and the browser stops reporting new ones once the user taps, scrolls or presses a key — so LCP is the largest thing that had appeared by the moment you touched the page. Only the visible part inside the viewport counts, and an image is measured at the smaller of its displayed and intrinsic size, so stretching a hero past its natural resolution buys nothing. First Contentful Paint (FCP) is when any content paints; LCP is deliberately more selective.
LCP decomposes into four subparts, and the source publishes a healthy split:
•
time to first byte — about 40%
•
resource load delay — under 10%
•
resource load duration — about 40%
•
element render delay — under 10%
Two of those are meant to be nearly nothing, so a large one names your bug. A big load delay means the browser learned about the image late: injected by script, or buried in a stylesheet as a background image, instead of sitting in the initial HTML. A big render delay means the bytes were already in memory while a render-blocking stylesheet or a synchronous script in the head held the main thread.
So the reflex — blame the framework, compress the image again — is usually wrong. Two fixes do more. Never lazy-load the largest image; the guidance is unambiguous that it always adds delay. And since images are not render-blocking they do not start at top priority, so the one the metric is timing has to ask:
<img src="/hero.webp" fetchpriority="high" width="1200" height="675" alt="Product on a desk">
Rule: split LCP into its four subparts before touching a byte — the one out of proportion names the fix.
Cumulative Layout Shift: the metric that keeps counting
~2 min
CLS scores content jumping under the reader. It is not the sum of every shift but the worst burst, and it accrues for the page's whole life. The element that appears is not what scores; the content it shoves is.
A single shift scores impact fraction times distance fraction, both relative to the viewport: impact is how much of the viewport the moving elements covered across two frames, distance is the furthest any traveled over the viewport's largest dimension. Both are fractions, so the score has no unit.
Those scores are not added up. Shifts less than a second apart form a burst, a burst runs at most five seconds, and your CLS is the worst such window — one ugly reflow outranks forty tiny ones.
Two misconceptions cause most of the confusion. First, that content appearing is a shift: it is not — adding an element, or an element resizing itself, does not count. What counts is the already-visible content it pushes. Second, that a shift the user caused is forgiven automatically: shifts within 500 milliseconds of a discrete input are flagged hadRecentInput and can be excluded, but that does not extend to continuous interactions.
Every fix is one idea — reserve the space before the content arrives. width and height on an image give the browser a default aspect ratio, so the box exists before a byte does; an ad, embed or iframe slot gets min-height or aspect-ratio; a cookie bar gets space reserved rather than injected above everything else.
Motion has its own rule: a composited translate cannot move other elements, while animating top, left or box-shadow re-runs layout. Text reflows when the fallback font is replaced, which font-display: optional avoids.
Rule: any box whose contents arrive late must declare its size before they do.
Responsiveness: the main thread and the frame budget
~3 min
INP measures a click, tap or key press through to the next painted frame, so a handler that returns fast and leaves heavy rendering behind still scores badly. Underneath sits one thread and a ~16.7 millisecond frame: yield it, or make the change cheaper.
An interaction has three phases: input delay before your handler runs, processing while it runs, and presentation delay until the next frame is painted. The metric spans all three, and the common mistake is optimizing only the middle one — returning from a handler quickly and then queueing an expensive re-render scores as badly as a slow handler. Only clicks, taps and key presses are observed; scrolling, hovering and zooming are not.
It reports the worst interaction, discarding one high interaction per fifty. Two hundred crisp interactions and three terrible ones still report a terrible number — correctly, since those three are what the user remembers. When one tap fires several events, the longest sets the latency. This is why it replaced First Input Delay (FID), which saw only the input-delay phase of the first interaction: a page could score perfectly and be unusable from the second click.
Underneath sits one main thread. Any uninterrupted work over 50 milliseconds is a long task, and the blocking part is everything past that 50; input cannot be handled while it runs, which is why a fully loaded page can still feel dead. Handing the thread back is the fix:
for (const chunk of chunks) {
render(chunk);
await new Promise((resolve) => setTimeout(resolve, 0)); // a queued tap can run here
}
setTimeout(..., 0) puts the remainder into a separate task — though after five nested calls the browser imposes a minimum 5 millisecond delay. scheduler.yield() is built for this and resumes ahead of other queued tasks, but check support: it shipped in Chrome and Edge 129 and Firefox 142, and Safari does not support it.
Painting has its own budget. Most screens refresh sixty times a second — one frame every 16.66 milliseconds, of which about 10 are realistically yours. Every visual change walks the same pipeline: JavaScript, style, layout, paint, composite. Touch geometry (width, left) and you pay all five; touch a paint-only property (color, box-shadow) and layout is skipped; change something needing neither and the browser goes straight to compositing. A card sliding in by animating left re-runs layout and paint every frame; the same motion as a composited translate skips both — identical result, and only one fits in ten milliseconds.
Rule: break long work where a user could plausibly tap, and make a change cheaper before making it smaller — everything between the tap and the painted frame is yours.
JavaScript is the most expensive thing you ship
~2 min
A kilobyte of script is not a kilobyte of image: it is parsed, compiled and executed on the thread that handles input. Split on routes and interactions, load the rest without blocking the parser, and make every third-party tag justify itself.
The mistake is budgeting script by download size. Script is expensive after it arrives: parsing, compiling and executing it occupies the main thread during startup — precisely when the page is trying to become responsive.
Two seams are worth splitting on: the route, where a client-side framework loads each route or component only when it is reached, and the interaction, loading a module inside the handler that needs it.
Keeping third-party dependencies in their own bundle helps twice over: they change far less often than your code, so they stay cached while your bundle turns over.
For what remains, loading strategy decides whether it blocks. A synchronous <script> delays construction of the Document Object Model (DOM), and rendering with it. defer runs after parsing finishes and before DOMContentLoaded, in document order; async runs the instant it lands, in any order, and can interrupt parsing. The source's rule: async when something must genuinely run early, defer otherwise.
Third-party tags deserve suspicion: you control neither their size nor their release schedule. Connecting early to an origin you will certainly use can save 100–500 milliseconds, but the browser discards a connection nothing used within ten seconds. Self-hosting a vendor script buys caching and connection reuse you control, and costs you their automatic updates, security fixes included.
Rule: default every script to defer, split on the route and the interaction, and treat a third-party tag as code you must justify.
Before the first byte, and the queue behind it
~2 min
Time to First Byte counts everything before your HTML starts arriving. After that it is a queue, and hints nudge the order rather than overriding it. The as attribute on a preload is not decoration: without it the file is fetched twice.
TTFB is a sequence, and its order tells you who owns the problem: redirect time, service-worker startup, the domain-name lookup, connection setup and security negotiation, then the request until the first byte comes back. Good is 0.8 seconds or less and poor is over 1.8 — but it is a diagnostic, not a headline metric, so hitting good is not compulsory. It matters because it is roughly 40% of a healthy largest paint.
After that it is a queue, and hints are how you edit it — cheapest first:
•
dns-prefetch resolves a Domain Name System (DNS) name only. Lookups are cheap, so this can fan out across speculative origins.
•
preconnect adds the connection and the Transport Layer Security (TLS) negotiation, which is real cost. Reserve it for critical origins, and add crossorigin for resources fetched that way, such as fonts, or the browser opens a second connection anyway and the hint bought nothing.
•
preload fetches something the preload scanner cannot discover by itself. The as attribute is mandatory in practice: without it the file is downloaded twice.
•
prefetch is a low-priority fetch for a page the visitor will probably visit next. If they do not, you spent their data.
The misconception is that a hint jumps the queue. It does not. Images start at low priority because they are not render-blocking; after layout, an image found inside the initial viewport is promoted to high, and fetchpriority="high" only gets it there sooner. Nothing lifts a resource above what the browser already knows it needs in order to render.
Rule: spend hints on the few resources the largest paint depends on, and count round trips before bytes.
Images and fonts: the assets that decide both metrics
~2 min
Images are usually the largest paint and fonts a classic source of shift, and both are governed by declarations rather than code. Pick a modern format, describe your layout honestly in sizes, and choose a font-display value on purpose.
AVIF generally compresses best — over 50% smaller than JPEG in some cases — while WebP is the widely supported modern default, beating JPEG, PNG and GIF and keeping transparency under lossy compression, which JPEG cannot.
Responsive images need two attributes that only work together. srcset with w descriptors states each file's intrinsic width; sizes states the width you will display it at. The misconception is that sizes describes the image — it describes your layout, and when the two disagree the browser confidently picks the wrong candidate, usually a larger one.
<picture> does two jobs: a <source> with type is format fallback, and one with media is art direction — media is a command the browser must obey, where srcset is only a hint. Separately, loading="lazy" defers an image until it is near the viewport, and decoding="async" moves decoding off the rendering path.
Fonts have one fact that explains most surprises: an @font-face block downloads nothing. The file is fetched only when styling actually used on the page refers to it — which is why the font your first paint needs is worth preloading, and why unicode-range withholds a file until a matching character appears.
font-display then puts the whole trade in one property. block hides text for 2–3 seconds and swaps whenever the font lands; swap shows the fallback immediately and always swaps; fallback hides for 100 milliseconds and allows a swap for 3 seconds; optional hides for 100 milliseconds and never swaps — the only value that cannot reflow your text.
Rule: declare the box, describe the layout, and pick the font policy deliberately.
Don't fetch it again — or fetch it before it is asked for
~2 min
Caching is two decisions: how long a response may be reused without asking, and what happens when that runs out. The versioned-URL pattern makes repeat visits nearly free, no-cache does not mean what it looks like, and Vary: User-Agent quietly disables caching.
Cache-Control: max-age=<seconds> sets how long a response stays fresh, counted from when the response was generated, not from when your browser filed it away.
The directive names are where people go wrong. no-cache stores the response and revalidates before each reuse; no-store is the one that means keep nothing; must-revalidate says a stale copy may not be served without checking first.
Revalidating is cheaper but not free: an ETag or Last-Modified turns the re-fetch into a 304 Not Modified with no body, so you save the bytes and still pay the round trip.
The pattern that removes both costs is a content hash in the filename served with a year of freshness: change the content and the URL changes, so there is nothing to invalidate and nothing to check. immutable completes it by stopping revalidation on an ordinary reload, which otherwise behaves as though freshness were zero.
Shared caches — a Content Delivery Network (CDN) or a proxy — are steered separately: s-maxage sets their freshness alone, private keeps a personalized response out of them, and stale-while-revalidate serves the stale copy instantly while refreshing behind it. Vary adds request headers to the cache key, which is why Vary: User-Agent is close to a cache-disable: the strings vary so widely that almost nothing is a hit.
Beyond reuse there is anticipation. A speculationrules script declares URLs to prefetch or prerender in Chromium browsers; prerendering executes the next page's JavaScript in advance, which makes activation feel instant and is why it belongs only on navigations you are confident about.
Rule: hash the URL and cache it forever; fall back to revalidation only where the URL cannot change.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.