Frontend / Checklist
Hit the Core Web Vitals thresholds on purpose
Three numbers decide whether a page is fast: LCP, INP and CLS, measured on real visits at the 75th percentile. Everything else on this page - loading order, asset weight, caching, fonts, rendering - is a lever on one of those three, and each rule names the header, attribute or tool that moves it.
43 rules in 7 groups. Adapted from the Front-End Checklist (David Dias, MIT), rewritten and re-verified September 2026.
What this checklist decides
Whether the page meets the Core Web Vitals thresholds for real users, and which loading, asset, caching and rendering decisions stand in the way. Every rule is measurable - by Lighthouse in CI, by WebPageTest from a real location, or by the field data in PageSpeed Insights - so "feels fast on my laptop" never has to be the answer.
The source lists 43 rules in seven groups, including a 17-rule Metrics group that mixes measurement with clean-up. That group is split here into Measure it and Trim what you ship; nothing was dropped or merged, so the count is the source's. Cloudflare Pages is assumed as the default host, because Brotli, HTTP/3 and the edge cache come free there and several rules collapse to "already done".
- Critical blocks the ship
- High fix before launch
- Medium fix this quarter
- Low worth it when cheap
Web Vitals
The thresholds are Google's published ones, all judged at the 75th percentile of page loads: LCP 2.5 seconds, INP 200 milliseconds, CLS 0.1, and FCP 1.8 seconds for the diagnostic that feeds LCP.
| Rule | Priority | What to do |
|---|---|---|
| Get Largest Contentful Paint under 2.5 seconds | Critical | Find the LCP element (the Performance panel in DevTools names it), make it server-rendered text or a preloaded image with fetchpriority="high", and clear the render-blocking CSS and the slow TTFB in front of it. LCP is the one Vital that fails a page on its own. |
| Keep Cumulative Layout Shift under 0.1 | High | Reserve every box before it fills: width and height on images and iframes, min-height on ad and embed slots, a font strategy that does not reflow the page on swap, and no content inserted above what has already painted. |
| Keep Interaction to Next Paint under 200 milliseconds | High | INP reports the slowest interaction of the whole visit, not the average. Break long tasks with scheduler.yield(), move work out of the input handler, and keep the DOM small so style and layout finish before the next frame. |
| Get First Contentful Paint under 1.8 seconds | High | FCP is the first text or image on screen, gated by TTFB and by render-blocking stylesheets and fonts. Inline the critical CSS, load the rest without blocking, and give the body font font-display: swap so text paints in the fallback face. |
| Treat "load under 3 seconds" as a proxy, not a Vital | High | The source keeps a 3-second load target. Onload time is not a Core Web Vital and says little about what the user saw or could click; keep it as an internal budget if it is already tracked, but the numbers that matter are LCP, INP and CLS at the 75th percentile. |
Gotcha: a Lighthouse score is a lab number from one simulated device. The Vitals that count come from real visits - the Chrome UX Report in PageSpeed Insights, or your own beacons from the web-vitals library. A 95 in CI and a failing LCP in the field are the same page.
Loading
What the browser fetches, in what order, and how much of it the user ever needed. The biggest group on the page and the one with the most wins per hour.
| Rule | Priority | What to do |
|---|---|---|
| Load non-critical code on interaction | High | Chat widgets, video embeds, share dialogs and date pickers get a static facade and a dynamic import() on the first click or hover. The user who never opens it never downloads it. |
| Load code when its content nears the viewport | High | An IntersectionObserver with a generous rootMargin triggers the import for carousels, maps and comment threads shortly before they scroll into view, so the code is ready by the time the user gets there. |
| Lazy-load offscreen content | High | loading="lazy" on below-the-fold images and iframes, content-visibility: auto on long sections so the browser skips their layout until they approach. Never a library for what the platform does natively. |
| Show a loading state for anything slower than instant | High | A skeleton or spinner inside the space the content will occupy, with aria-busy="true" on the region while it is pending. Reserving the space is what keeps the loading state from becoming a layout shift when the content lands. |
| Use resource hints deliberately | High | preconnect for the two or three origins the first paint depends on, preload for the LCP image and the one above-the-fold font, prefetch for the next route. Hints are a budget: every unnecessary one competes with the critical path. |
| Tame third-party scripts | High | Tag managers, analytics and widgets load with defer or async, after the LCP element is on screen. Self-host what licensing allows, measure each script's cost with the third-party badges in the Performance panel, and delete the ones nobody reads the reports from. |
| Set fetchpriority where the browser guesses wrong | Medium | fetchpriority="high" on the LCP image and the fetch that renders the first screen; fetchpriority="low" on below-the-fold images and carousel frames the parser would otherwise request early. Everything else keeps the default. |
| Never lazy-load above the fold | Medium | loading="lazy" on the hero delays its request until layout has run, which is the opposite of what you want. Images in the first viewport load eagerly - the default - and the largest one also carries the high priority. |
| Register a service worker for repeat visits | Medium | Workbox, or a short hand-written worker: cache-first for hashed assets, stale-while-revalidate for HTML, and a precached app shell. Never cache an API response you cannot invalidate, and version the worker so a deploy actually replaces it. |
| Ship an offline fallback page | Low | Once the worker exists, a static /offline/ page served when the network fails costs a few lines and replaces the browser's error screen with something in your own voice. |
| Prefetch and prerender likely navigations | Low | The Speculation Rules API prerenders the next page in the background so the click feels instant. Use "eagerness": "moderate" or "conservative" on link patterns; Chromium honors the rules and other engines ignore the block harmlessly. The snippet is under Navigation and rendering below. |
The resource-hint row in practice - one connection opened early, two fetches started before the parser would find them:
<!-- head, as early as possible -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preload" as="image" href="/img/hero.png" fetchpriority="high">
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin.woff2" crossorigin> Gotcha: a font preload without crossorigin is fetched twice, even from your own origin, because font requests are always CORS-mode and the preload without the attribute does not match. A preload without as is fetched at the wrong priority and then fetched again.
Assets
How many bytes cross the wire and in how many requests. The one group with a byte budget in it, and the budget is the source's.
| Rule | Priority | What to do |
|---|---|---|
| Compress text with Brotli | High | Brotli for HTML, CSS, JS, SVG and JSON, gzip as the fallback. Cloudflare Pages and Cloudflare's CDN apply it automatically, as do most edge hosts; confirm with the content-encoding: br response header instead of assuming. |
| Cut the request count | High | Bundle the small modules, inline the tiny critical CSS, use one SVG sprite for icons, and drop the font weights nobody uses. HTTP/2 makes each request cheaper, not free; the request count in the Network panel is the metric. |
| Keep page weight under 1500KB | High | The source's ceiling is 1500KB transferred, with 500KB as the target. Measure it in the Network panel with the cache disabled; images and fonts are where the overrun almost always lives, and the images checklist is where it gets fixed. |
| Keep the CSS small | Medium | Purge unused rules (Tailwind's compiler does this by default; PurgeCSS or Lightning CSS for hand-written stylesheets), minify, and split per route only when one stylesheet is genuinely large. The Coverage tab in DevTools shows the unused share per file. |
| Keep the JavaScript bundle small | Medium | Route-level code splitting, tree-shakable named imports instead of a whole barrel, a look at bundlephobia before adding a dependency, and a bundle analyzer in CI (rollup-plugin-visualizer, webpack-bundle-analyzer) so growth is visible in the PR. |
Gotcha: page weight is counted on transfer, not on disk. A 300KB script that ships as 90KB of Brotli is 90KB against the budget - but it is still 300KB to parse and compile on a phone, which is why the JavaScript row is its own line.
Caching
Two rules that make the second visit nearly free and the first visit served from the nearest city.
| Rule | Priority | What to do |
|---|---|---|
| Cache hashed assets forever and HTML briefly | High | Content-hashed CSS, JS and fonts get Cache-Control: public, max-age=31536000, immutable. HTML gets a short max-age or must-revalidate with an ETag, so a deploy is visible on the next request. The hash in the filename is what makes the year safe. |
| Put a CDN in front of everything | High | Static output on Cloudflare Pages is already on the edge; anything with an origin sits behind Cloudflare, Fastly or CloudFront. The CDN is where the Brotli, the HTTP/3 and the edge cache come from, and it is the only way a visitor on another continent gets a fast first byte. |
The two header values, whether they come from a Cloudflare Pages _headers file, a zone rule or an origin config:
# Hashed, content-addressed assets (main.a1b2c3d4e5.js): a year, never revalidated
Cache-Control: public, max-age=31536000, immutable
# HTML documents: always revalidate, so the request after a deploy sees the new page
Cache-Control: public, max-age=0, must-revalidate
ETag: "7c5dcf5d" Gotcha: immutable is only safe on a filename that changes when the content does. Put it on /js/main.js and the next deploy is invisible until the cache expires; a ?v= query string does not fix that, because it is not a new file to every cache in the chain.
Measure it
The first half of the source's Metrics group: the tools that tell you where the time goes, and the two tag-related rules that only apply when Google tags are on the page.
| Rule | Priority | What to do |
|---|---|---|
| Run WebPageTest on the pages that matter | High | WebPageTest from a real location on a throttled connection, with the filmstrip and the waterfall, is where the actual LCP element and the request order show up. Test the home page, the top landing page from analytics and the slowest template, not just the URL you happen to have open. |
| Cut the critical request chain short | High | HTML to CSS to font to paint is three round trips before anything shows; HTML to JS to JSON to paint is worse. Lighthouse lists the chains under "Avoid chaining critical requests". Preload the deep links, inline the tiny ones, and render the first screen from HTML rather than from a fetch. |
| Implement Consent Mode v2 if you run Google tags | High | This row only exists when GA4, Google Ads or Google Tag Manager is on the page. Then Consent Mode v2 is what lets those tags honor the consent banner and keep modeled conversions in the EEA and UK. No Google tags, no work here - and no reason to add them for it. |
| Audit in a real browser, not only a CLI | Medium | Lighthouse inside DevTools with CPU and network throttling on, the Performance panel for the LCP element and the long tasks, and the Web Vitals extension for field-like numbers while you browse. Lighthouse CI catches regressions; the browser explains them. |
| Get Time to First Byte down | Medium | TTFB is the floor under every other metric. Static HTML from the edge is the fastest case; for dynamic pages, cache the response at the CDN, keep the database in the same region as the runtime, and stream the response so the first byte does not wait on the last query. |
| Ship source maps for production | Medium | Build with source maps on and upload them to the error tracker from CI (Sentry, Bugsnag and Rollbar all have an upload step), so a production stack trace points at the original line instead of a column offset in a minified file. Serve them publicly only if the source has nothing to hide. |
| Keep Google Tag Manager lean | Medium | Every tag in the container runs on every page it matches. Audit the container, delete tags nobody reads, fire the rest on DOM Ready or later, and load the container itself after the LCP. If the container holds two tags, drop GTM and load them directly. |
| Eliminate render-blocking resources | Medium | A stylesheet in the head blocks paint; a synchronous script blocks parsing. Inline the critical CSS, load the rest asynchronously (a per-route stylesheet, or rel="preload" as="style" swapped in on load), and give every script defer or type="module". |
Gotcha: Lighthouse's "Reduce unused JavaScript" counts bytes not executed during its own page load. Code for a modal nobody opened during the audit is reported as unused, which is fine if it is behind a dynamic import and a false alarm if it is not.
Trim what you ship
The second half of the Metrics group: fonts, protocol, and the accumulated weight that a bundle analyzer finds in any project older than a year.
| Rule | Priority | What to do |
|---|---|---|
| Load fonts so text is never invisible | High | WOFF2 only, self-hosted, font-display: swap (or optional for a face that is not the brand), unicode-range subsets so a Latin reader never fetches Cyrillic, and one preload for the face used above the fold. A variable font replaces four weight files with one. |
| Serve over HTTP/2 or HTTP/3 | High | Multiplexing and header compression make many small requests cheap; HTTP/3 over QUIC recovers from packet loss per stream, which is most of the mobile win. Cloudflare serves both by default. Check the Protocol column in the Network panel - h2 or h3, never http/1.1. |
| Replace animated GIFs with video | Medium | An animated GIF is a stack of full frames with no real compression. Convert it to MP4 or WebM and use <video autoplay muted loop playsinline> with a poster; the result behaves the same and weighs a fraction. |
| Keep the DOM small and shallow | Medium | Every node costs memory, style recalculation and layout time on every interaction, which is an INP problem before it is a load problem. Lighthouse flags an oversized DOM; the fixes are virtualizing long lists, deleting wrapper divs, and rendering collapsed sections only when they open. |
| Remove duplicate libraries | Medium | Two copies of React, lodash next to lodash-es, moment beside dayjs. The bundle analyzer shows them, npm ls <package> finds the second copy, and overrides in package.json pins one version for every dependency that asks. |
| Keep libraries current and secure | Medium | npm audit in CI, Dependabot or Renovate opening the update PRs, and a policy that a major version gets adopted within a quarter. Old libraries carry known CVEs and, almost always, a larger bundle than their current release. |
| Never redirect with JavaScript | Medium | A window.location assignment after load means the user downloaded, parsed and rendered a page just to leave it. Redirect at the edge with a 301 or 308 - a _redirects file on Pages, a zone rule, or the origin's router - so the wrong page is never rendered. |
| Stop shipping legacy JavaScript | Medium | Target current browsers (a browserslist of defaults and not dead, or Baseline widely available), so the bundler emits no ES5 transforms and no core-js polyfills for features every shipping engine has. A type="module" script is already skipped by the browsers that would need them. |
| Preconnect to critical third-party origins | Medium | <link rel="preconnect"> for the image CDN, the API origin or the font host - whichever the first paint waits on. Each one opens a connection whether it is used or not, so the list stays at two or three; dns-prefetch is the cheaper hint for everything else. |
The font row as a declaration - one variable file, swapped in when it arrives, only fetched for the characters the page uses:
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400 700; /* one variable file covers the range */
font-display: swap; /* fallback text paints immediately */
src: url("/fonts/inter-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD; /* Latin subset; Cyrillic and Greek get their own blocks */
} Gotcha: swap guarantees a visible flash from the fallback face; optional avoids it but may leave the fallback in place for the whole visit on a slow connection. Pick per face - the brand headline gets swap plus a preload, body text can take optional - and size the fallback with size-adjust so the swap does not shift the layout.
Navigation and rendering
The source's Navigation and Rendering groups, one and two rules each, share a section: what happens after the first page, and how the browser gets HTML it can paint before the server is done.
| Rule | Priority | What to do |
|---|---|---|
| Keep pages eligible for the back/forward cache | High | The bfcache makes the back button instant by freezing the whole page. No unload listeners (use pagehide), no Cache-Control: no-store on HTML, and close open connections on pagehide. The Back/forward cache panel under Application in DevTools says exactly why a page was excluded. |
| Virtualize long lists and tables | High | Render only the rows in view once a list runs to hundreds of items: TanStack Virtual, react-window or vue-virtual-scroller in a framework, content-visibility: auto with contain-intrinsic-size for a long static page. The DOM stays small and INP stays under the line. |
| Stream HTML before the response is complete | Medium | A server that streams (React's renderToPipeableStream, the Next.js App Router, SvelteKit, Astro's server islands) sends the shell at once and the slow parts as their data resolves. TTFB drops to the time of the first flush, and the head - with its preloads - reaches the browser before the database query returns. |
The Speculation Rules snippet promised under Loading - prerender the section pages a hovering pointer is likely to visit, prefetch everything else except account routes:
<script type="speculationrules">
{
"prerender": [{
"where": { "href_matches": "/stacks/*" },
"eagerness": "moderate"
}],
"prefetch": [{
"where": { "and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/account/*" } }
] },
"eagerness": "conservative"
}]
}
</script> Gotcha: a prerendered page runs its scripts, so analytics fire for a visit that may never happen. Check document.prerendering and send the page-view beacon from the prerenderingchange event instead of on load.
Keep going
The rows that block a deploy - LCP, CLS, INP, the preload and the caching headers - are collected with the other disciplines in the launch checklist. Most LCP problems are image problems, and the images checklist is the full treatment; the CSS checklist covers critical CSS and compositor-only animation, and the JavaScript checklist covers code splitting, long tasks and memory leaks in detail.
Half of the rules here are decided before a line is written, by where the HTML gets made. The rendering models guide is that decision, including the streaming and ISR options behind the TTFB rows, and the hosting guide covers the platforms that put a global CDN in front of the site by default.
To keep the numbers after launch, feed real-user Vitals from the web-vitals library into the same place as the error tracking the monitoring guide settles on, and pick the bundler behind the code-splitting and bundle-size rows from the build tools guide.