On this page
Start with field data, not Lighthouse
Google assesses Core Web Vitals on real Chrome users, collected in the Chrome UX Report (CrUX). A page passes when the 75th percentile of its page loads meets all three thresholds, with mobile and desktop judged separately.
- LCP (Largest Contentful Paint): good within 2.5 s, poor above 4 s.
- INP (Interaction to Next Paint): good at 200 ms or less, poor above 500 ms.
- CLS (Cumulative Layout Shift): good at 0.1 or less, poor above 0.25.
Open the Core Web Vitals report in Search Console first. It groups similar URLs and tells you which metric fails on which template, so you know whether to look at product pages, the dashboard or the marketing site. PageSpeed Insights then shows the CrUX numbers for a single URL next to a lab run. Lighthouse is useful for debugging a fix, but a good lab score does not mean your users pass.
CrUX only covers public pages with enough traffic, and it tells you what is slow, not why. So we also collect our own field data with Google’s web-vitals library. The attribution build names the element behind each score, which turns “INP is poor” into “the status filter on the orders page is slow”.
import { onCLS, onINP, onLCP } from 'web-vitals/attribution'
const targetOf = ({ name, attribution }) => ({
LCP: attribution.target,
INP: attribution.interactionTarget,
CLS: attribution.largestShiftTarget,
})[name]
function report(metric) {
navigator.sendBeacon('/rum', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
target: targetOf(metric),
path: location.pathname,
}))
}
onLCP(report)
onINP(report)
onCLS(report)sendBeacon sends no CSRF token, so the /rum route needs to sit outside Laravel’s CSRF middleware, or be an API route. Store the rows somewhere cheap and look at the 75th percentile per template, just as CrUX does.
What changes when the app is an Inertia app
Only the first page a visitor opens is a full page load. Every later Inertia visit swaps the page component with JavaScript and updates the URL. CrUX still treats that whole session as one page view of the landing URL, so INP and CLS from later visits are counted against the page the user arrived on, and LCP is only measured once.
Two things follow. First, landing pages carry the LCP score, so fix the templates people arrive on from search before the ones they click through to. Second, a slow table deep inside the app can fail INP for your home page. Chrome 151 added soft navigation APIs that measure each client-side route separately, and recent web-vitals releases can report them with a reportSoftNavs option. CrUX does not use them yet, as the web.dev FAQ on single-page apps explains, so treat per-route numbers as a debugging aid for now.
LCP: get the main content into the first HTML
1. Turn on SSR and prove it runs
Without server-side rendering, the first response of an Inertia app is an empty root element and a JSON page object. Nothing is painted until the JavaScript bundle has downloaded, parsed and rendered, and the hero image is not even requested until then. With SSR, a Node process renders the page to HTML before Laravel sends it, so the browser can paint text and find images straight away. Search engines get the same HTML, which also helps indexing.
In Inertia 3 the @inertiajs/vite plugin handles SSR during development, and the SSR guide covers production. Build both bundles, run the SSR server under a process monitor such as Supervisor and restart it on every deploy.
npm ci
npm run build # "vite build && vite build --ssr"
php artisan optimize
php artisan inertia:stop-ssr --graceful # Supervisor starts it againThe catch: when rendering fails, Inertia quietly falls back to client-side rendering. The site keeps working and nobody notices that SSR is off. We add php artisan inertia:check-ssr to the health checks and set INERTIA_SSR_THROW_ON_ERROR=true in the test environment, so a component that touches window during render fails the build instead of production. In the Inertia apps we audit, a missing or silently failing SSR server is the most common reason for a poor LCP.
Logged-in areas that search engines never see may not need SSR at all. The $withoutSsr property on HandleInertiaRequests excludes route patterns such as admin/*, which saves the Node render time on every request there.
2. Make the LCP image easy to find
If the largest element is an image, it should be in the HTML, requested early and at high priority. Put fetchPriority="high" on that one <img> in React, and never give it loading="lazy". If the image is a CSS background, or you are not using SSR yet, preload it from the root Blade template. withViewData passes a value to Blade without sending it to the page component.
{{-- Controller: Inertia::render(...)->withViewData(['lcpImage' => $url]) --}}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@isset($lcpImage)
<link rel="preload" as="image" href="{{ $lcpImage }}" fetchpriority="high">
@endisset
@viteReactRefresh
@vite('resources/js/app.tsx')
<x-inertia::head />
</head>For responsive images, add imagesrcset and imagesizes to the preload so it matches what the <img> will request. Serve the image at the displayed size in a modern format such as WebP or AVIF, and give high priority to one or two images at most.
3. Load fonts without holding back text
When the LCP element is a headline, the web font can decide the score. Self-host WOFF2 files, preload only the one or two weights used above the fold and use font-display: swap. Recent versions of the Laravel Vite plugin can do this for you through a fonts option and the @fonts Blade directive.
4. Bring down time to first byte
Every step above waits for the first byte. A slow Laravel response shows up in LCP for every page, and SSR adds a render on top of it. The usual fixes, in order:
- Cache the framework. Run
php artisan optimizeon deploy and make sure OPcache is on. - Keep shared props cheap.
share()inHandleInertiaRequestsruns on every request. One slow query there slows the whole app. - Defer what is below the fold.
Inertia::defer()loads slow props in a second request after the page renders. Never defer the data the LCP element needs. - Cache expensive reads.
Cache::flexible()serves a slightly stale value while it refreshes in the background, so no visitor waits for the rebuild.
INP: keep the main thread free
INP measures the time from a click, tap or key press until the browser paints the result. Any task that runs longer than 50 ms blocks the main thread, and in React apps the long tasks usually come from three places.
- Hydration. After an SSR page arrives, React has to hydrate it before clicks do anything. The more JavaScript and props a page carries, the longer that takes. Inertia 3 lazy-loads each page component into its own chunk by default, so keep it that way, and send only the fields a page renders rather than whole models.
- Heavy widgets and third-party scripts. Charts, rich text editors, maps and chat widgets rarely need to load before the first interaction. Load them with
lazy(), or after the page has loaded. - Expensive re-renders. A search box that filters a large table on every key press is the classic INP failure in admin screens.
import { lazy, Suspense, useDeferredValue, useState } from 'react'
import { OrdersTable } from './OrdersTable' // wrapped in memo()
const RevenueChart = lazy(() => import('./RevenueChart'))
export default function Index({ orders }: { orders: Order[] }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<OrdersTable orders={orders} query={deferredQuery} />
<Suspense fallback={<div className="h-80" />}>
<RevenueChart orders={orders} />
</Suspense>
</>
)
}The input updates at once, and the table re-renders with the deferred value in the background, where React can interrupt it. This only helps if OrdersTable is wrapped in memo. The chart loads in its own chunk. With renderToString, which Inertia’s SSR uses, the server sends the Suspense fallback in place of a lazy component, so never lazy-load anything that holds the LCP element.
With pages split into chunks, Vite::prefetch() in a service provider fetches them after the first load, so later Inertia visits do not wait on the network. It will not change CrUX scores, but the app feels faster.
CLS: reserve space before content arrives
Layout shifts within 500 ms of a click or key press do not count, so an accordion that opens on click is fine. What counts is content that appears on its own and pushes the page down.
- Images and video: always set
widthandheight, or reserve the box with CSSaspect-ratio. - Deferred props and lazy components: give every
DeferredandSuspensefallback the height of the content it replaces. A one-line “Loading…” above a tall table is a shift waiting to happen. - Banners and flash messages: show them as overlays, or in a slot whose height is reserved from the start, not inserted above the content.
- Fonts: a fallback font with different metrics moves text when the web font swaps in. Metric overrides such as
size-adjustin the fallback@font-faceclose the gap. The Laravel Vite plugin can generate them when thefontainepackage is installed.
Causes and first fixes at a glance
| Typical cause in a Laravel and Inertia app | First fix | |
|---|---|---|
| LCP | No SSR, or SSR silently falling back to the client | Run the SSR server in production and health-check it |
| LCP | Hero image requested late or lazy-loaded | High fetch priority on the image, or a preload in the root template |
| LCP | Slow server response | Framework caches, cheap shared props, deferred props |
| INP | Large bundle and long hydration | Lazy page chunks, lazy widgets, smaller props |
| INP | Re-rendering a large list on every key press | A deferred value or a transition, with a memoised list |
| CLS | Fallbacks, images and banners without reserved space | Fixed dimensions and fallbacks at full height |
| CLS | Web font swap | Metric-adjusted fallback font |
A fix is finished when the field data moves, not when Lighthouse turns green.
Proving the fix
CrUX is a rolling 28-day window, so a fix shipped today is only fully reflected about four weeks later. Your own web-vitals data shows the change within days. To stop it coming back, we run Lighthouse CI in GitHub Actions on the key templates and fail the pull request when a budget is broken. Lab numbers are not field numbers, but they catch a new render-blocking script or a lazy-loaded hero before it ships.
Summary
Measure in the field first and find the template and element behind each failing metric. Then work in order: SSR that really runs, an LCP image found early, fonts and a fast server, smaller bundles and cheaper renders for INP, reserved space for CLS. It is the order we follow on technical SEO projects and in our Laravel development work.
If your Laravel and Inertia app is stuck in the red and you want the fixes merged rather than listed in a report, send us a short brief.
Frequently asked questions
What are good Core Web Vitals scores?
LCP within 2.5 seconds, INP of 200 milliseconds or less and CLS of 0.1 or less. Google measures them at the 75th percentile of real page loads, for mobile and desktop separately, and a page passes only when all three are good.
Does Inertia SSR improve Core Web Vitals?
It usually improves LCP, because the browser receives rendered HTML and can find the main image straight away. It also helps search engines index the page. It costs a Node process in production, and Inertia falls back to client-side rendering when SSR fails, so check that it is really running.
Why is my Lighthouse score good but Core Web Vitals still fail?
Lighthouse is a single lab run on one simulated device. Core Web Vitals come from real Chrome users over 28 days, on slower phones and networks, and include interactions and layout shifts that happen after the page has loaded. Use Lighthouse to debug, and field data to decide.


