Largest Contentful Paint is the render time of the biggest image or text block in the viewport. Interaction to Next Paint is the worst interaction latency of the visit: input delay, handler execution, and the wait for the next paint. Cumulative Layout Shift is the largest burst of unexpected movement, scored as impact fraction times distance fraction over a session window of at most 5 seconds. All three are judged at the 75th percentile of real page loads.
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP | <= 2.5 s | 2.5-4.0 s | > 4.0 s |
| INP | <= 200 ms | 200-500 ms | > 500 ms |
| CLS | <= 0.1 | 0.1-0.25 | > 0.25 |
Measure a production build only: in next dev the modules are unbundled, React 7,897 runs its development checks and the dev overlay sits on the page. Listing 5.k injects web-vitals 6.2.2 8,626 before any page script runs, throttles network and CPU as DevTools does, types into a filter box to produce an interaction, and fakes the tab going away so CLS and INP are finalized.
import { chromium } from 'playwright'
import { readFileSync } from 'node:fs'
const wv = readFileSync('node_modules/web-vitals/dist/web-vitals.attribution.iife.js', 'utf8')
const browser = await chromium.launch()
const context = await browser.newContext()
await context.addInitScript(`${wv}
window.__v = {}; const o = { reportAllChanges: true }
const save = (m) => { window.__v[m.name] = m }
webVitals.onLCP(save, o); webVitals.onCLS(save, o); webVitals.onINP(save, o)`)
const page = await context.newPage()
const cdp = await context.newCDPSession(page)
await cdp.send('Network.enable')
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 150,
downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8 })
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 })
await page.goto(process.argv[2], { waitUntil: 'load' })
await page.waitForTimeout(3000)
await page.click('#q')
await page.keyboard.type('Item 12', { delay: 250 })
await page.waitForTimeout(2000)
await page.evaluate(() => { // end the visit so CLS and INP are reported
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
})
for (const m of Object.values(await page.evaluate(() => window.__v)))
console.log(m.name, Math.round(m.value * 1000) / 1000, m.rating)
await browser.close()== /slow LCP 1928 good CLS 0.09 good INP 256 needs-improvement
/slow uses a plain <img> with no intrinsic size, a client component that statically imports highlight.js 24,999 , and a filter that recomputes on every keystroke. The same script against /fast, which uses next/image, next/dynamic and useDeferredValue, reported LCP 828, CLS 0 and INP 32. Unthrottled over localhost the two are indistinguishable, because the image arrives before the first paint. Summing encodedBodySize over the resource entries turns this script into the byte counter used below. Compare medians: LCP for /slow ranged from 1.87 s to 1.93 s across runs, its INP from 184 ms to 296 ms.