Performance Is a Business Metric, Not a Technical One
Let us start with shared ground: performance optimisation is not an engineer's self-improvement exercise. It maps directly onto revenue. The correlations the industry keeps reproducing include:
- Every extra second of load time measurably drops mobile conversion
- Walmart measured roughly a 2% conversion lift per second of improvement
- Pinterest cut perceived wait time by 40% and saw search traffic and sign-ups rise about 15%
The exact numbers vary by industry and by baseline, so do not copy them. The direction is remarkably consistent: faster pages convert better.
Beyond conversion, performance is also an SEO input — Google states plainly that Core Web Vitals are a ranking signal. That moves performance from "nice to have" to "must pass."
For caching and compression response headers, check them entry by entry against our HTTP headers cheat sheet.
Core Web Vitals: Know What Is Being Measured
| Metric | What it measures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP Largest Contentful Paint | Loading: when the largest content element renders | ≤ 2.5s | 2.5–4s | > 4s |
| INP Interaction to Next Paint | Responsiveness: delay from user action to painted update | ≤ 200ms | 200–500ms | > 500ms |
| CLS Cumulative Layout Shift | Visual stability: accumulated unexpected layout movement | ≤ 0.1 | 0.1–0.25 | > 0.25 |
Three details matter:
- INP officially replaced FID in March 2024. FID only measured the input delay of the first interaction, excluding processing and rendering, so it badly understated real jank. INP measures the full latency of almost all interactions. Any material still talking about FID is out of date.
- The 75th percentile is what counts. Not the average — three quarters of real visits must pass. A healthy average is irrelevant if the long tail fails.
- All three must pass together. Missing one means "failing" overall.
Lab Data vs Field Data
| Source | Characteristics | Use it for |
|---|---|---|
| CrUX / the GSC Core Web Vitals report | Real users, 28-day rolling, split by device | The only pass/fail verdict |
| Lighthouse / PageSpeed Insights | Lab, fixed conditions, single run | Diagnosing problems, listing opportunities |
| WebPageTest | Lab, tunable network/device/location | Deep analysis, waterfall inspection |
Disagreement between them is normal — see the FAQ at the end.
The Critical Rendering Path: What Actually Holds Up First Paint
Roughly, the browser must: parse HTML → build the DOM → load and parse CSS → build the CSSOM → execute JS → build the render tree → lay out → paint.
Only two kinds of resources block rendering: CSS and synchronous JavaScript.
CSS Must Block — So Minimise Its Scope
CSS blocks rendering for good reason; otherwise you get a flash of unstyled content. The right move is not to remove the blocking but to shrink it:
- Inline the critical CSS (the minimum needed for first paint) so above-the-fold content depends on no external stylesheet
- Load non-critical CSS asynchronously:
<!-- Critical CSS inlined -->
<style>/* above-the-fold styles */</style>
<!-- Non-critical CSS, non-blocking -->
<link rel="preload" href="/full.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/full.css"></noscript>
Free JavaScript with defer / async
| Attribute | When it executes | Order guaranteed | Use for |
|---|---|---|---|
| None (sync) | As soon as downloaded; blocks parsing | Document order | Tiny scripts that must run first |
async |
As soon as downloaded | No | Fully independent scripts (analytics, ads) |
defer |
After parsing, before DOMContentLoaded | Yes | Scripts touching the DOM or each other |
type="module" |
Behaves like defer by default | Yes | Modern modular code |
Default to defer. Reserve async for genuinely independent third-party scripts.
Fonts: Non-Blocking and Jump-Free
Web fonts are a double risk to both LCP and CLS. Do three things together:
<!-- 1. Connect early -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- 2. Load without blocking: preload first, then use media="print" to dodge the
render-blocking path, switching to all on load -->
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter&display=swap"
media="print" onload="this.media='all'">
/* 3. Match the fallback's metrics to the target font so the swap does not shift text */
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107%;
}
display=swap keeps text readable immediately rather than blank during the wait, and metric matching drives CLS towards zero.
Images: The Single Biggest Win
Images routinely account for over half of a page's total bytes, which makes them the easiest place to find gains. Three dimensions, in order of payoff:
1. Dimensions (biggest payoff, most often ignored)
Dropping a 3000px-wide image into a 300px container is pure waste. Let the browser choose with srcset + sizes:
<img src="photo-800.jpg"
srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 800px"
width="800" height="600"
alt="Description"
loading="lazy" decoding="async">
To produce multiple sizes, our image resizer and image format converter run entirely in your browser.
2. Format (second biggest)
| Format | Versus JPEG | Alpha | Animation | Browser support | Use for |
|---|---|---|---|---|---|
| AVIF | ~50% smaller | Yes | Yes | Newer (Safari 16+) | Preferred; slow to encode, so batch offline |
| WebP | 25–35% smaller | Yes | Yes | Broad | The safe default |
| JPEG | Baseline | No | No | Universal | Photo fallback |
| PNG | Larger | Yes | No | Universal | Lossless or transparency where SVG does not fit |
| SVG | Tiny | Yes | Possible | Universal | Icons, logos, simple graphics |
Let the browser choose with <picture> rather than sniffing in JavaScript:
<picture>
<source type="image/avif" srcset="photo.avif">
<source type="image/webp" srcset="photo.webp">
<img src="photo.jpg" width="800" height="600" alt="Description">
</picture>
For a full feature comparison, see the image formats cheat sheet.
3. Loading Timing
- Do not lazy-load the LCP image — it delays LCP. Use
fetchpriority="high"and preload it instead - Lazy-load everything below the fold with
loading="lazy" - Add
decoding="async"so decoding does not block the main thread
<!-- LCP element: load with priority -->
<img src="hero.jpg" fetchpriority="high" decoding="async" width="1200" height="600" alt="Hero">
<!-- Below the fold: lazy -->
<img src="item.jpg" loading="lazy" decoding="async" width="300" height="200" alt="List item">
JavaScript and CSS Size
JavaScript: Split Before You Compress
| Technique | What it does | Priority |
|---|---|---|
| Route-level splitting | Ship only the code this page needs | Highest |
| Drop unused dependencies | Use a bundle analyser to spot outsized packages | High |
| Dynamic import | Load heavy features (editors, charts, PDF) on demand | High |
| Tree shaking | Requires ESM and a correct sideEffects declaration |
Medium |
| Minification | Most of its benefit is already eaten by gzip/brotli | Low |
A rule of thumb: be wary once compressed first-screen JS exceeds 300 KB. Check whether an editor, charting library, or PDF parser got bundled into the first screen before reaching for a minifier.
CSS: Deleting Beats Compressing
- Remove unused CSS (Tailwind, PurgeCSS and similar)
- Inline critical CSS (above)
- For normalisation and minification, our CSS formatter and JS formatter handle the code, and JSON minify handles JSON payloads
The Network Layer
Caching Strategy
| Resource type | Recommended policy | Example |
|---|---|---|
| Hashed static assets | Long-lived, immutable | Cache-Control: max-age=31536000, immutable |
| HTML documents | Revalidate every time | Cache-Control: no-cache plus ETag |
| API responses | Short, or business-specific | Cache-Control: private, max-age=60 |
| Images and media | Medium plus revalidation | Cache-Control: public, max-age=86400, must-revalidate |
The core pattern: put a content hash in the filename (app.a1b2c3.js), so changing content changes the URL and you can safely cache for a year. Never hard-cache the HTML, so a release takes effect immediately.
Compression and Protocols
- Prefer Brotli, fall back to gzip — Brotli is typically another 15–20% smaller
- HTTP/2 or HTTP/3 — multiplexing means domain sharding is not merely useless but actively harmful
- A CDN — push static assets to edge nodes near your users
- Connect early —
preconnectfor critical third-party origins,dns-prefetchfor secondary ones
CLS: Six Causes and Their Fixes
| Cause | Typical scenario | Fix |
|---|---|---|
| Unsized media | <img> without width and height |
Add width/height, or CSS aspect-ratio |
| Injected content | Ads, notice bars, lazy blocks | Reserve a fixed-height container or min-height |
| Font swapping | FOUT/FOIT reflows text | Preload the font, match metrics with size-adjust, use font-display: optional |
| Layout-triggering animation | Animating top/left/width/height |
Animate only transform and opacity |
| Skeleton mismatch | Skeleton dimensions differ from real content | Make the skeleton match the real layout |
| Async UI injection | Cookie banners, A/B variants | Use an overlay (position: fixed) instead of inserting into flow |
INP: Break Up Long Tasks
A poor INP almost always means the main thread is held by long tasks, so interactions cannot be handled promptly.
| Technique | How |
|---|---|
| Split long tasks | Break anything over 50 ms into chunks, yielding with scheduler.yield() or setTimeout |
| Ship less JS | Less first-screen JS means faster parse and execute |
| Defer third parties | Load analytics, ads, and chat widgets on demand or when idle |
| Avoid layout thrashing | Batch DOM reads and writes; never interleave them in a loop |
| Use a Web Worker | Move pure computation off the main thread |
| Keep the DOM lean | More nodes means slower style recalculation and layout |
// Break a long loop into chunks that yield to the main thread
async function processAll(items) {
for (let i = 0; i < items.length; i++) {
process(items[i]);
if (i % 50 === 0) await scheduler.yield(); // let the browser handle input
}
}
Measurement Tools
| Tool | Data type | Best for |
|---|---|---|
| GSC Core Web Vitals report | CrUX field data | Deciding pass/fail |
| PageSpeed Insights | Both | One-stop view: field data plus lab suggestions |
| Lighthouse (DevTools) | Lab | Local iteration, specific opportunities |
| WebPageTest | Lab, tunable | Waterfalls, multiple locations, throttling |
| CrUX Dashboard / BigQuery | Field, segmentable | Long-term trends and segmentation |
Pre-Launch Checklist
- [ ] LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1 (CrUX 75th percentile, not a Lighthouse score)
- [ ] Every
<img>/<video>/<iframe>haswidthandheight(oraspect-ratio) - [ ] The LCP element is not lazy-loaded and carries
fetchpriority="high" - [ ] All below-the-fold images use
loading="lazy" - [ ] Images served as AVIF/WebP with a JPEG/PNG fallback
- [ ] Critical CSS inlined, non-critical CSS loaded without blocking
- [ ] JS defaults to
defer; third-party scriptsasyncor deferred - [ ] Fonts load without blocking with
display=swap, and fallbacks are metric-matched - [ ] Static assets are hashed with
max-age=31536000, immutable - [ ] Brotli or gzip enabled
- [ ] Compressed first-screen JS kept under 300 KB
- [ ] No long task over 50 ms holding the main thread
Related Resources
For images, our image compressor, image format converter, and image resizer all run locally in the browser with nothing uploaded. For format characteristics see the image formats cheat sheet, and for caching and compression headers see the HTTP headers cheat sheet.