Web Development
How to Fix Slow Page Load Speed and Core Web Vitals in Production
Fix slow page load and failing Core Web Vitals: TTFB and CDN caching, LCP image priority, long JavaScript tasks, third-party scripts, and Lighthouse CI budgets.
The campaign went live at 9 a.m. Marketing had booked a big paid-social push, and the landing page was the only destination. By 9:40, the ad dashboard showed clicks pouring in and almost nobody converting. Bounce rate sat at 71%.
The developer who built the page opened it on a MacBook. Lighthouse said 94. Everything looked fine.
Then someone checked field data. LCP at the 75th percentile: 5.8 seconds, on mid-range Android phones over 4G. That's where the clicks were coming from. The hero was a 2.4 MB PNG. The JavaScript bundle weighed 1.8 MB. A tag manager loaded fourteen third-party scripts. The origin took 1.4 seconds just to start answering because each request triggered uncached database work.
Every visitor who left cost real money. Ad platforms bill for clicks, not for page views that finish loading.
A good lab score does not guarantee a good experience on a customer's phone. Here's how to find the actual bottleneck and fix it.
The anatomy of a slow page#
Page loading is a chain. When one part is slow, everything that depends on it waits. Start with these areas.
1. Time to First Byte (TTFB). The browser cannot start processing the response until the server sends the first byte. An uncached server-rendered page that triggers N+1 database queries, or an origin far from the user, can consume a large part of the performance budget before the browser has parsed the HTML. TTFB is a diagnostic metric, not one of the three Core Web Vitals, but it often helps explain a poor LCP result.
2. Render-blocking resources. Stylesheets that apply to the current page can delay rendering while the browser processes CSS. A synchronous script can also pause HTML parsing while it downloads and executes. Large assets make the delay worse, but shipping CSS and JavaScript the current route never uses is often the bigger problem.
3. Late discovery of the LCP element. The browser can discover an image in the initial HTML early. It may discover a hero image set as a CSS background-image, or inserted by JavaScript after hydration, much later. Adding loading="lazy" to the hero is another self-inflicted delay: it tells the browser to defer the image most visitors need first. A 3000px-wide PNG sent to a 390px-wide phone also wastes bandwidth.
4. Main-thread congestion. Downloading JavaScript is only part of the cost. The device must still parse, compile, and execute it. A task that runs for more than 50 ms is a long task. While JavaScript occupies the main thread, the browser has less opportunity to respond to taps and update the screen. That can hurt INP. Third-party scripts are frequent offenders because they run on the same main thread as your application code.
Layout shifts are another problem. If images have no intrinsic dimensions, or a late-loading font changes text metrics, content can move after the visitor starts reading. That contributes to CLS. Explicit image dimensions let the browser reserve the correct aspect ratio before the image downloads, reducing image-related shifts.
The three Core Web Vitals are LCP, INP, and CLS. At the 75th percentile of real-user page visits, the good thresholds are:
| Metric | Good threshold |
|---|---|
| Largest Contentful Paint (LCP) | ≤ 2.5 seconds |
| Interaction to Next Paint (INP) | ≤ 200 ms |
| Cumulative Layout Shift (CLS) | ≤ 0.1 |
TTFB is useful supporting data, but it is not a Core Web Vital. The 75th percentile matters: it shows the experience of users who are slower than the median, not just what happens on your development machine.
Countermeasures#
1. Measure real users before changing code#
Start with field data. A Lighthouse run is useful for reproducing problems, but it cannot represent every device, network, location, or user interaction.
The web-vitals attribution build provides diagnostic information that can help identify the element or interaction associated with a poor metric.
npm install web-vitals
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution';
function send(metric) {
const attribution = metric.attribution || {};
const payload = {
name: metric.name,
value: metric.value,
rating: metric.rating,
path: location.pathname,
target:
attribution.target ||
attribution.interactionTarget ||
attribution.largestShiftTarget ||
null,
connection: navigator.connection?.effectiveType || null,
};
navigator.sendBeacon('/rum/vitals', JSON.stringify(payload));
}
onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);
This is a starting point for a real-user monitoring (RUM) integration. Implement /rum/vitals on your server, validate incoming fields, and avoid collecting personal or sensitive data in URLs or element selectors. The Network Information API is not available in every browser, so treat the connection field as optional.
Slice results by route, device class, and connection type. Fix the worst route first, not the site-wide average.
2. Reduce TTFB with caching that matches the content#
For HTML that is identical for most visitors, a CDN can serve cached responses and reduce trips to the origin. One possible policy is:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
Here, s-maxage=60 allows a shared cache to consider the response fresh for 60 seconds. After it becomes stale, stale-while-revalidate=300 allows a compatible cache to serve the stale response while it revalidates in the background, for up to the configured stale window.
Do not apply public shared caching indiscriminately to personalized pages. Authenticated dashboards, account pages, and responses containing user-specific data generally need private or carefully keyed caching. A cache configuration that mixes customer responses can become a data-exposure incident, not just a freshness bug.
For fingerprinted static assets (files with a content hash in the name), use long-lived caching:
Cache-Control: public, max-age=31536000, immutable
Then fix the slow database query that made the origin expensive in the first place. A CDN hides a slow query on cache hits and exposes it again on cache misses.
3. Make the LCP image easy to discover#
Give the hero image responsive sources, modern formats, intrinsic dimensions, and high fetch priority. Do not lazy-load the image that is likely to become the LCP element.
<picture>
<source
type="image/avif"
srcset="/img/hero-480.avif 480w, /img/hero-800.avif 800w,
/img/hero-1200.avif 1200w, /img/hero-1600.avif 1600w"
sizes="100vw">
<source
type="image/webp"
srcset="/img/hero-480.webp 480w, /img/hero-800.webp 800w,
/img/hero-1200.webp 1200w, /img/hero-1600.webp 1600w"
sizes="100vw">
<img
src="/img/hero-800.jpg"
width="1600"
height="900"
alt="Product on a desk"
fetchpriority="high"
decoding="async">
</picture>
The browser uses srcset and sizes to choose an appropriate source. The width and height attributes provide an aspect ratio so the browser can reserve space before the image loads. fetchpriority="high" tells the browser that the resource is important. Images below the fold are better candidates for loading="lazy".
For a Next.js application, use the framework's image component and configure responsive sizes appropriately. Avoid adding high priority to multiple images; prioritize the actual LCP candidate.
4. Reduce long tasks and control third-party scripts#
Split JavaScript by route and load heavy features only when users need them with dynamic import(). Run a bundle analyzer and look for large dependencies, duplicate packages, or a full date library used for one formatting operation.
If you must process a large amount of data on the main thread, split the work into smaller chunks and yield periodically:
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return globalThis.scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processRows(rows) {
let sliceStart = performance.now();
for (const row of rows) {
heavyWork(row);
if (performance.now() - sliceStart >= 8) {
await yieldToMain();
sliceStart = performance.now();
}
}
}
The time budget is more useful than yielding after a fixed number of rows because individual rows may take different amounts of time. One expensive heavyWork() call can still block the main thread, though. Optimize that operation or move suitable CPU-heavy work to a Web Worker.
Third-party tags need the same scrutiny. Load only the scripts required on a particular route, and defer non-essential work when the business requirements allow it.
For a Next.js App Router project, the built-in Script component provides loading strategies. For example:
import Script from 'next/script';
export function AnalyticsScript() {
return (
<Script
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXX"
strategy="afterInteractive"
/>
);
}
This is only the script-loading example. A working Google Tag Manager integration also needs the correct container initialization, data layer, consent configuration, and event setup. afterInteractive still loads relatively early; lazyOnload is an option for scripts that can wait until the browser is idle.
Do not defer conversion or consent events without discussing the measurement impact with the marketing and privacy teams. Visitors who leave before a delayed tag loads may not generate the events your analytics setup expects.
5. Reduce font-related layout shifts#
Preload only the font file needed for above-the-fold content. Use font-display: swap and a fallback whose metrics are close to the web font.
<link
rel="preload"
href="/fonts/brand-var.woff2"
as="font"
type="font/woff2"
crossorigin>
@font-face {
font-family: "Brand";
src: url("/fonts/brand-var.woff2") format("woff2");
font-display: swap;
}
@font-face {
font-family: "Brand Fallback";
src: local("Arial");
size-adjust: 104%;
ascent-override: 92%;
descent-override: 24%;
}
body {
font-family: "Brand", "Brand Fallback", sans-serif;
}
The metric override values above are illustrative. Tune them against the actual font and fallback; incorrect values can make the shift worse. Avoid preloading several font weights when one variable font file can cover the required range.
6. Add performance budgets to CI#
Performance can regress one ordinary pull request at a time. Add Lighthouse CI assertions so the build pipeline can flag significant changes.
For example:
{
"ci": {
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["error", { "maxNumericValue": 300 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 250000 }]
}
}
}
}
These are example budgets, not universal pass/fail values. Run Lighthouse CI with consistent mobile settings and a representative test route. Lab measurements can vary between runs, so establish a baseline and avoid thresholds so tight that normal runner variation creates constant failures.
Total Blocking Time (TBT) is a lab metric that can help diagnose main-thread work; it is not a replacement for field INP. Keep both lab diagnostics and real-user measurements in the workflow.
The trade-offs#
Every performance fix has a cost.
- Caching can serve stale content. The example policy allows a shared cache to reuse stale content during background revalidation. That may be acceptable for an article page, but not for a balance, inventory count, or customer-specific dashboard. Define cache rules by route and data sensitivity.
- AVIF encoding uses compute. Generating modern formats during a build can increase build time. Generating them through an image CDN moves some of that cost to the service bill. Measure the savings against the operational cost.
- Deferring analytics can affect attribution. Visitors who leave before a delayed script runs may not produce the expected events. Agree on the trade-off with marketing and privacy stakeholders before changing tracking behavior.
- Code splitting trades initial bytes for more requests. Additional chunks can create request waterfalls on slow connections. Prefetch likely next routes selectively; prefetching every route wastes bandwidth.
- Lab budgets can be noisy. CI runners vary. Use stable test conditions, establish a baseline, and investigate repeated regressions rather than training the team to ignore red builds.
- Lazy-loading the wrong image hurts LCP. Use it for below-the-fold media, not the image that needs to appear immediately.
Before the next campaign#
Ask marketing for the next launch date before the morning of the campaign. Then test the landing page in Chrome DevTools with CPU throttling and a slow network profile. Compare the lab result with mobile RUM data for that specific route.
If p75 LCP is over 2.5 seconds, inspect the LCP element and its resource timing. Find out whether the delay comes from TTFB, late image discovery, image transfer time, or render delay. Fix the measured bottleneck, deploy the change, and confirm the result in both lab tests and field data.
If you discover the problem at 9:40 a.m. on launch day, the campaign is already paying for it.
Official references#
- Google web.dev: Web Vitals — Core Web Vitals definitions, thresholds, and field measurement.
- Google Chrome: Optimize LCP — Diagnosing and improving Largest Contentful Paint.
- Google Chrome: Optimize INP — Reducing interaction latency and main-thread work.
- MDN: Cache-Control — Shared caching,
s-maxage, andstale-while-revalidatebehavior. - Next.js: Scripts — Loading third-party scripts with Next.js strategies.
- GoogleChrome/web-vitals — Measuring Core Web Vitals and using attribution data.
- MDN: JavaScript performance — Long tasks and yielding to the main thread.
- GoogleChrome/lighthouse-ci — Automating Lighthouse checks in CI.