This guide is the markup and config that move the metrics, for the code-curious who already know what LCP, INP and CLS are. We will prioritise the critical path with resource hints and fetchpriority, unblock rendering with deferred scripts and inlined critical CSS, lazy-load what is offscreen, and code-split so the main thread stays responsive. For diagnosis from traces and the wider strategy, see our advanced performance and performance engineering guides.
Your largest contentful paint is usually a hero image. Make sure the browser finds and fetches it early: preload it, give it a high fetchpriority, and never lazy-load it.
After:
<!-- in <head>: fetch it early --> <link rel="preload" as="image" href="/hero.jpg" fetchpriority="high"> <!-- the element: eager, high priority --> <img src="/hero.jpg" fetchpriority="high" width="1200" height="630" alt="…">
The width and height reserve space and prevent layout shift (CLS). Lazy-loading is for offscreen images only — applying it to the LCP element is one of the most common self-inflicted LCP failures.
Synchronous scripts and full stylesheets in the head block first paint. Defer scripts and inline only the CSS needed for the initial view, loading the rest without blocking:
<!-- scripts: defer so they don't block parsing -->
<script src="/app.js" defer></script>
<!-- critical CSS inline; rest loaded non-blocking -->
<style>/* critical above-the-fold CSS here */</style>
<link rel="preload" href="/full.css" as="style"
onload="this.rel='stylesheet'">
Use defer for scripts that touch the DOM (it preserves order and runs after parsing); async only for independent third-party tags. Either keeps the parser moving instead of stalling on a blocking <script>.
If your LCP or fonts come from another origin, warm the connection so the handshake is not on the critical path:
<link rel="preconnect" href="https://cdn.example.com" crossorigin> <link rel="dns-prefetch" href="https://cdn.example.com">
A custom font that blocks text rendering delays paint. Let text show immediately in a fallback and swap when the font loads:
@font-face {
font-family: "Inter";
src: url("/inter.woff2") format("woff2");
font-display: swap; /* render fallback now, swap when ready */
}
Pair font-display: swap with a preload of the woff2 for your primary font so the swap happens fast.
INP is main-thread contention, so the fix is doing less work when the user interacts. Code-split so each route loads only its own JavaScript instead of one monolithic bundle:
// before: everything imported up front
import { Chart } from "./chart.js";
// after: load it only when needed
button.addEventListener("click", async () => {
const { Chart } = await import("./chart.js");
new Chart(el);
});
Dynamic import() splits that code into a separate chunk fetched on demand, keeping the initial bundle — and the main thread — light. Defer non-urgent work (analytics, offscreen rendering) and, for long tasks you cannot remove, break them up and yield so the browser can handle input between chunks:
async function processInChunks(items, work) {
for (let i = 0; i < items.length; i++) {
work(items[i]);
if (i % 50 === 0) await new Promise(r => setTimeout(r)); // yield
}
}
For images and iframes below the fold, native lazy loading defers their fetch until needed — just never the LCP element:
<img src="/below-fold.jpg" loading="lazy" width="800" height="600" alt="…"> <iframe src="/map" loading="lazy" width="600" height="400"></iframe>
Layout shift comes from elements that arrive without reserved space. Always set explicit dimensions on media and reserve space for anything injected (ads, banners):
img, iframe, video { aspect-ratio: attr(width) / attr(height); }
.ad-slot { min-height: 250px; } /* reserve before the ad loads */
Verify in the field, not just locally. Profile with the Page Speed test to see what is executing and blocking, check field scores with the Core Web Vitals check, and run a Site Audit to catch elements missing dimensions. Remember field data lags ~28 days, so confirm fixes there before declaring victory.
A page fails LCP and INP. The hero is lazy-loaded (so discovered late) and a render-blocking analytics script sits in the head; the route ships one large bundle. The coder preloads the hero with fetchpriority="high" and removes its loading="lazy", moves the analytics tag to defer, inlines critical CSS, and code-splits the dashboard widget behind a dynamic import(). Lab LCP drops immediately; over the next few weeks field INP on mid-range devices moves into the green as the main thread is no longer blocked on load. Every change was a few lines of markup or config.
Lazy-loading the LCP element. Leaving scripts synchronous in the head instead of defer. Loading the whole stylesheet render-blocking instead of inlining critical CSS. Shipping one giant bundle instead of code-splitting. Fonts with no font-display: swap. Media without width/height, causing CLS. And optimising against lab tools only — ranking uses field data at the 75th percentile.
Preload the LCP image and set fetchpriority="high", never lazy-load it, reserve its dimensions, and remove render-blocking resources from the critical path so it can paint early.
Use defer for scripts that touch the DOM — it preserves order and runs after parsing. Use async only for independent third-party scripts whose order does not matter. Both stop the script blocking the parser.
Ship and run less JavaScript: code-split with dynamic import(), defer non-urgent work, and break long tasks into chunks that yield to the main thread so interactions are handled promptly.
An attribute that hints the browser’s fetch priority. Set fetchpriority="high" on the LCP image (and its preload) so it loads ahead of less important requests, and lower it on non-critical resources.
Set explicit width and height (or aspect-ratio) on all media, and reserve space for injected content like ads and banners so nothing pushes content down when it loads.
In lab tools, yes. But ranking uses field data over a rolling ~28-day window at the 75th percentile, so confirm improvements in the field rather than from a single local run.