Core Web Vitals for the fullstack developer: what belongs to the server and what to the browser
Three numbers decide whether a page is "fast" for Google and for the people using it: LCP, INP and CLS, assessed at the 75th percentile of real visits, with thresholds of 2.5 seconds, 200 milliseconds and 0.1 (web.dev, Web Vitals, consulted on 2026-11-13). Whoever works on both the frontend and the backend has an advantage that a pure CSS specialist lacks: you know that a substantial part of LCP is decided before the browser has received the first byte, and that CLS often comes from a field missing in the server's template. In this article you build three small things on a test project, run in-process with Fastify's test client: the cache headers that remove bytes from the second load, a template function that never lets an image without dimensions out, and an endpoint that collects measurements from real visitors. The previous article in the series is GraphQL versus REST; this one deals with what it costs to get to the browser what it asked for.
What is really measured, and why the 75th percentile
An example makes the idea concrete. Four visits to a blog page report an LCP of 1.8, 2.1, 2.6 and 3.9 seconds. The mean would be 2.6 seconds, but the metric that counts is the 75th percentile: the value below which 75% of visits fall. Here it is 2.6 seconds, so the page does not pass the 2.5 threshold even though two visits out of four were excellent. The 75th percentile is a choice designed not to be fooled either by exceptional visits or by the worst tail: web.dev recommends measuring it separately for mobile and desktop, and treating the page as passing only if all three metrics meet the threshold.
The "poor" thresholds are not on the web.dev page but in the web-vitals library, which exports them as constants: 4000 ms for LCP, 500 ms for INP, 0.25 for CLS (read from the constants LCPThresholds, INPThresholds and CLSThresholds of version 6.2.3, imported in the listing that follows). In between lies the "needs improvement" band. A note on dates, because you can still read the opposite: on 12 March 2024 INP replaced FID as the responsiveness metric (web.dev, INP announcement), and the library no longer has a function for FID.
LCP: how much of it the server controls
web.dev breaks LCP into four consecutive times: the time to first byte (TTFB), the delay before the browser starts downloading the resource of the largest element, the duration of that download, and the render delay. As an order of magnitude the guide indicates about 40% for TTFB, less than 10% for the load delay, about 40% for the download duration and less than 10% for the render delay (web.dev, Optimize LCP), specifying that these are guidelines, not rules. The practical message is that two phases out of four depend almost entirely on the server: how long it takes to respond with the HTML and how heavy the bytes it has to send are.
The listing that follows shows the two headers with the greatest effect on the second load. The HTML carries Cache-Control: no-cache and an ETag: "no-cache" does not mean "do not store", it means "revalidate before use", so on a revisit the browser sends If-None-Match and the server answers 304 with no body. Assets instead have a content fingerprint in the name (hero.3f9c2a.avif) and receive max-age=31536000, immutable: the file with that name will never change, so the browser can keep it for a year without asking anything. If you remove the fingerprint from the name but leave immutable, the browser will no longer see your updates for a year: the fingerprint is what makes that value safe. The source for the immutable directive is MDN, Cache-Control.
import Fastify from 'fastify';
import { createHash } from 'node:crypto';
const app = Fastify();
const pagina = '<!doctype html><title>Demo</title><img src="/assets/hero.3f9c2a.avif" width="1200" height="630" alt="">';
const etag = '"' + createHash('sha256').update(pagina).digest('base64url').slice(0, 16) + '"';
// The HTML changes with every release: it can be stored, but must be revalidated every time.
app.get('/', async (req, reply) => {
reply.header('Cache-Control', 'no-cache').header('ETag', etag).type('text/html');
if (req.headers['if-none-match'] === etag) return reply.code(304).send();
return pagina;
});
// The name carries a content fingerprint: that file never changes, so one year.
app.get('/assets/:file', async (req, reply) => {
reply.header('Cache-Control', 'public, max-age=31536000, immutable').type('image/avif');
return Buffer.from('placeholder-bin');
});
for (const [etichetta, richiesta] of [
['HTML, first visit ', { url: '/' }],
['HTML, revisit ', { url: '/', headers: { 'if-none-match': etag } }],
['fingerprinted asset', { url: '/assets/hero.3f9c2a.avif' }],
]) {
const r = await app.inject(richiesta);
console.log(etichetta, r.statusCode, '|', r.headers['cache-control'], '| body:', r.rawPayload.length, 'bytes');
}
// Output:
// HTML, first visit 200 | no-cache | body: 103 bytes
// HTML, revisit 304 | no-cache | body: 0 bytes
// fingerprinted asset 200 | public, max-age=31536000, immutable | body: 15 bytes
The output, obtained with Node 24.11 and Fastify 5.12.8 and identical across two consecutive runs, shows the three cases: first visit (200 with 103 bytes), revisit of the HTML (304 and zero bytes in the body) and fingerprinted asset (one-year cache). The 103 bytes belong to the test document, not an estimate for a real page. The listing measures no times and opens no ports: it uses inject(), which passes the request into the server without a network. For this reason it proves the correctness of the headers, not the speed: speed is measured in field data, in the last section.
The second phase of LCP remains, the delay before the download starts. If the largest element is an image, the browser does not discover it until it reads the HTML, and by default does not give it high priority, because images do not block rendering. web.dev recommends fetchpriority="high" on the image that is likely to be the LCP, and warns never to make that image lazy (loading="lazy"), because loading would only start after layout. In the next listing this rule is not a recommendation that someone has to remember, but a property of the template function.
CLS: dimensions are a server-side datum
The most frequent cause of CLS listed by web.dev is the image without dimensions: the browser does not know how much space to reserve, and the page shifts when the image arrives. The guidance is simple: always set width and height on images and videos, or reserve the space with aspect-ratio in CSS (web.dev, Optimize CLS). For a fullstack developer the interesting point is who owns those dimensions: the backend knows them, reading them from the file at upload time and saving them together with the address. If the template makes them mandatory, an image without dimensions never leaves the server.
// Server-side template function: it rejects images without dimensions
// and does not allow lazy-loading the image that will be the LCP.
export function img({ src, alt, width, height, lcp = false }) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
throw new Error(`img ${src}: width and height are required (positive integers)`);
}
const attributi = lcp ? 'fetchpriority="high"' : 'loading="lazy" decoding="async"';
return `<img src="${src}" alt="${alt}" width="${width}" height="${height}" ${attributi}>`;
}
console.log(img({ src: '/a/hero.avif', alt: 'Cover', width: 1200, height: 630, lcp: true }));
console.log(img({ src: '/a/foto.avif', alt: 'Photo', width: 800, height: 450 }));
try { img({ src: '/a/rotta.avif', alt: '', width: 800 }); } catch (e) { console.log('Error:', e.message); }
// Output:
// <img src="/a/hero.avif" alt="Cover" width="1200" height="630" fetchpriority="high">
// <img src="/a/foto.avif" alt="Photo" width="800" height="450" loading="lazy" decoding="async">
// Error: img /a/rotta.avif: width and height are required (positive integers)
The img function does two things. It throws an error if width or height are not positive integers: the last line of the output shows the message for an image missing its height. Then it distinguishes the two cases with a single parameter: the lcp image receives fetchpriority="high" and cannot become lazy, all the others receive loading="lazy" and decoding="async". The listing proves nothing about rendering, because there is no browser: it proves that the HTML produced has the right attributes. If your frontend is Angular, the same idea carries over to the component that renders the image, but I have not run that code.
INP: your contribution is not blocking the main thread
INP measures the latency of interactions and web.dev breaks it into three parts: the input delay (from the gesture to the start of the handlers), the processing duration, and the presentation delay, that is the time to show the next frame (web.dev, Optimize INP). The server has little to do with these three phases, but the way you design the API does. A huge response to process in the browser, a list that has to recompute everything at every keystroke, or a handler that waits for the network before updating the interface lengthen the second and third phases. The fixes are almost all in the frontend, and for those I refer you to the web.dev texts: I did not reproduce them here, because without a browser I have no way of measuring an INP.
- Input delay: break up long tasks on the main thread, because the interaction waits for the current task to finish.
- Processing duration: do only the visible update in the handler and defer the rest.
- Presentation delay: reduce the amount of DOM and styles to recalculate for the next frame.
The list follows the path of the event, from gesture to screen, and the phase to act on depends on where your site loses time: to find out, guessing is not enough, you need field data with attribution, which are the subject of the next section. If the largest share is the input delay, optimizing the handler changes almost nothing.
Collecting visitor measurements: client and server
Lab scores tell you what could go wrong; field data tells you what goes wrong for the people using the site. The web-vitals library provides the functions onLCP, onINP and onCLS, and an attribution variant that explains where a high value comes from: for LCP it returns the four times of the breakdown above, for INP the three of the interaction. The client that follows was compiled with TypeScript 7.0.2 in strict mode against web-vitals 6.2.3, but not run, because it requires a browser. A detail you only discover by compiling: in version 6 the LCP attribution calls the element's selector target; there is no element field: writing m.attribution.element the compiler answers with error TS2339, "Property 'element' does not exist on type 'LCPAttribution'", which I tried in a separate file.
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals/attribution';
type Invio = { name: string; value: number; path: string; detail: Record<string, unknown> };
const coda = new Map<string, Invio>();
function accoda(m: Metric & { attribution: object }): void {
coda.set(m.id, { name: m.name, value: m.value, path: location.pathname, detail: { ...m.attribution } });
}
function svuota(): void {
if (coda.size === 0) return;
for (const invio of coda.values()) {
const corpo = new Blob([JSON.stringify(invio)], { type: 'application/json' });
navigator.sendBeacon('/rum', corpo);
}
coda.clear();
}
onLCP(accoda);
onINP(accoda);
onCLS(accoda);
// The last reliable moment to send is when the page goes to the background.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') svuota();
});
The two choices that matter are the queue and the moment of sending. Metrics can fire more than once, for example CLS grows while the page lives, so the queue is a map indexed by the metric's id: the latest value replaces the previous one. The right moment to send is visibilitychange to hidden, the last reliable event before the page disappears, and the means is sendBeacon, designed to go out even while the page is being unloaded, with a limit of about 64 KiB for the total data in the queue (MDN, sendBeacon). The body is a Blob with type application/json: if you passed the string directly, the Fetch Standard assigns it the type text/plain;charset=UTF-8 (Fetch Standard, body extraction), and your server would have to know that.
The server below is the side you must trust least. Anyone can send a POST to your endpoint, so the body is validated with a schema: name allowed only among three values, numeric value that is non-negative and has a maximum, path of limited length. The listing tests both ways the body can arrive, the declared JSON and the text/plain, then two wrong submissions (a negative value and a metric name that no longer exists, FID) and finally computes the 75th percentile with a second independent calculation as confirmation.
import Fastify from 'fastify';
import { LCPThresholds, INPThresholds, CLSThresholds } from 'web-vitals';
const SOGLIE = { LCP: LCPThresholds, INP: INPThresholds, CLS: CLSThresholds };
const raccolta = [];
const app = Fastify();
// sendBeacon with a string travels as text/plain: we convert it ourselves.
app.addContentTypeParser('text/plain', { parseAs: 'string' }, (req, body, done) => {
try { done(null, JSON.parse(body)); } catch (e) { done(e); }
});
app.post('/rum', {
schema: {
body: {
type: 'object',
required: ['name', 'value', 'path'],
properties: {
name: { enum: ['LCP', 'INP', 'CLS'] },
value: { type: 'number', minimum: 0, maximum: 600000 },
path: { type: 'string', maxLength: 200 },
detail: { type: 'object', maxProperties: 20 },
},
additionalProperties: false,
},
},
}, async (req, reply) => {
raccolta.push(req.body);
return reply.code(204).send();
});
function p75(valori) {
const ordinati = [...valori].sort((a, b) => a - b);
return ordinati[Math.ceil(ordinati.length * 0.75) - 1];
}
function giudizio(nome, v) {
const [buono, scarso] = SOGLIE[nome];
return v <= buono ? 'good' : v <= scarso ? 'needs-improvement' : 'poor';
}
const j = JSON.stringify;
const invii = [
['application/json', j({ name: 'LCP', value: 1800, path: '/blog', detail: { resourceLoadDelay: 40 } })],
['text/plain;charset=UTF-8', j({ name: 'LCP', value: 2100, path: '/blog' })],
['text/plain;charset=UTF-8', j({ name: 'LCP', value: 2600, path: '/blog' })],
['text/plain;charset=UTF-8', j({ name: 'LCP', value: 3900, path: '/blog' })],
['text/plain;charset=UTF-8', j({ name: 'INP', value: -5, path: '/blog' })],
['text/plain;charset=UTF-8', j({ name: 'FID', value: 20, path: '/blog' })],
];
for (const [tipo, corpo] of invii) {
const r = await app.inject({ method: 'POST', url: '/rum', headers: { 'content-type': tipo }, payload: corpo });
console.log(r.statusCode, tipo.split(';')[0], corpo);
}
const lcp = raccolta.filter((m) => m.name === 'LCP').map((m) => m.value);
const v = p75(lcp);
console.log('LCP accepted:', lcp.length, 'p75 =', v, 'ms ->', giudizio('LCP', v));
// Second, independent calculation: p75 is the smallest value with at least 75% of the samples <= to it.
const minimo = Math.min(...lcp.filter((x) => lcp.filter((y) => y <= x).length / lcp.length >= 0.75));
console.log('check by counting:', minimo, minimo === v ? 'equal' : 'DIFFERENT');
// Output:
// 204 application/json {"name":"LCP","value":1800,"path":"/blog","detail":{"resourceLoadDelay":40}}
// 204 text/plain {"name":"LCP","value":2100,"path":"/blog"}
// 204 text/plain {"name":"LCP","value":2600,"path":"/blog"}
// 204 text/plain {"name":"LCP","value":3900,"path":"/blog"}
// 400 text/plain {"name":"INP","value":-5,"path":"/blog"}
// 400 text/plain {"name":"FID","value":20,"path":"/blog"}
// LCP accepted: 4 p75 = 2600 ms -> needs-improvement
// check by counting: 2600 equal
Four submissions are accepted (204) and two rejected (400), and the 75th percentile of the four LCPs is 2600 ms, so "needs improvement", consistent with the example at the beginning. The second calculation, by counting (the smallest value with at least 75% of the samples equal or lower), gives the same number. Two notes on the code. The text/plain parser must be registered explicitly: without it, Fastify delivers the body as a raw string (verified in a separate listing: the body type turns out to be string), and the schema would reject it. And the percentile here uses the nearest-rank method on a few samples: on real data you need many samples per measurement session, but even the exact percentile says nothing without segmenting by mobile and desktop.
What you can do now
You can break a high LCP into its four times and say which two belong to the server; you can set the cache headers for HTML and fingerprinted assets without risking serving stale content; you can stop an image without dimensions from leaving the template; and you can collect visitor measurements with an endpoint that validates them. What you do not have yet is the automation of these checks on every release: the last article in the series covers it, on multi-environment CI/CD. For in-depth production measurement, with CrUX and regressions, there is the article Measuring and monitoring Core Web Vitals in production, and for the attack surface of beacons and public endpoints the next one in the series, Web security. The other articles in the series, from the first to the eighth, are on the series page.
Frequently asked questions
Why the 75th percentile and not the mean?
Because web.dev gives it as the threshold to measure, separating mobile and desktop. The mean is dragged by exceptionally fast or slow visits; the 75th percentile says that three visits out of four were at least this good.
Why sendBeacon and not fetch?
Because the page may close at the very moment you want to send the data, and an ordinary asynchronous request risks being cancelled. sendBeacon is designed to go out without slowing the unload, with a cap of about 64 KiB of queued data.
Can I use immutable on any file?
No, only on files whose name changes when the content changes, for example with a fingerprint in the name. On a stable address such as the page's HTML, immutable would block updates until expiry.
Sources
- Web Vitals (web.dev) - good thresholds at the 75th percentile and the criterion of segmentation by device, consulted on 2026-11-13
- Interaction to Next Paint becomes a Core Web Vital on March 12 (web.dev) - replacement of FID with INP, consulted on 2026-11-13
- Optimize Largest Contentful Paint (web.dev) - the four subparts and the indicative proportions, fetchpriority and the ban on lazy-loading the LCP image, consulted on 2026-11-13
- Optimize Cumulative Layout Shift (web.dev) - width, height and aspect-ratio on media, consulted on 2026-11-13
- Optimize Interaction to Next Paint (web.dev) - the three parts of an interaction, consulted on 2026-11-13
- Cache-Control (MDN) - meaning of immutable and use with fingerprinted file names, consulted on 2026-11-13
- Navigator.sendBeacon() (MDN) - sending during page unload and the 64 KiB limit, consulted on 2026-11-13
- Fetch Standard (WHATWG) - text/plain;charset=UTF-8 type assigned to strings when a body is extracted, consulted on 2026-11-13
- GoogleChrome/web-vitals - onLCP, onINP, onCLS and use of sendBeacon; version 6.2.3 from the npm registry, consulted on 2026-11-13
The Node listings (cache, template, measurement collection) were run with Node 24.11, Fastify 5.12.8 and inject(), twice with identical output; no port opened. The TypeScript client was compiled with TypeScript 7.0.2, not run: there is no browser. The "poor" thresholds were read from the constants exported by web-vitals 6.2.3. No loading time was measured.
Commenti
Caricamento commenti...
Sign in to comment