One Core Web Vital sample. Five metrics, one entry each, once per document.
| Field | Value |
|---|---|
| Name | web_vital |
| Severity | 9 (INFO) |
| Written by | Browser tag. |
| Turned off by | data-auto-vitals="false" |
The first time the tab is hidden, which is the first moment the numbers are final and the last moment anything is guaranteed to be sent.
One entry per metric per document, and not repeated on client-side navigations: the largest contentful paint of a single-page app happened once, and re-reporting it on every route would turn one measurement into a pile of copies that drag an average around.
A metric the browser does not implement is simply absent, which is the honest answer. CLS is the exception in the other direction: zero is a real CLS and a good one, so it is reported whenever the browser gave us an observer to measure it with, and left out only when it did not.
| Attribute | Type | What it holds |
|---|---|---|
| firstrun.metric | string | LCP, INP, CLS, FCP or TTFB. |
| firstrun.value | number | The sample, rounded to three decimals: enough for CLS, invisible for the millisecond metrics. |
| firstrun.unit | string | ms. Absent for CLS, which is unitless. |
Every entry also carries the resource its client sends once per batch: session.id, user.id once you have called user(), and the rest of the list on the overview. Anything you pass yourself lands in the same map, and your key wins on a collision.
No rating. Sending good, needs improvement or poor would mean shipping Google's table inside a 4KB budget and then storing the answer on every single row. The server has the same table and classifies when you read, which is also the only way a change to the thresholds ever reaches samples already collected.
No attribution either: which element was the largest paint, or which interaction was the slowest. That is most of what a vitals library weighs, and this product has nowhere to put it.
And no url. The sample belongs to the document, not to a route, so it cannot be grouped by page on its own. Its session.id is the join back to the visit that produced it.
Google's numbers, applied at read time. Under the good column is good, over the poor column is poor, and in between needs improvement.
| Metric | What it measures | Good | Poor |
|---|---|---|---|
| LCP | Largest contentful paint: when the main thing appeared. | 2500 ms | 4000 ms |
| INP | Interaction to next paint: the worst interaction of the visit. | 200 ms | 500 ms |
| CLS | Cumulative layout shift: the worst five-second window of movement. | 0.1 | 0.25 |
| FCP | First contentful paint: when anything appeared. | 1800 ms | 3000 ms |
| TTFB | Time to first byte, from navigation timing. | 800 ms | 1800 ms |
A widget is a filter, a group by, an aggregate, a time bucket and a limit. These are those five parts written out, and every one of them is something you can build yourself.
| Question | The query |
|---|---|
| LCP at the 75th percentile | Name is web_vital . firstrun.metric is LCP . 75th percentile of firstrun.value |
| Every metric at a glance | Name is web_vital . group by firstrun.metric . 75th percentile of firstrun.value |
| Worst layout shift seen | Name is web_vital . firstrun.metric is CLS . maximum of firstrun.value |
| Whether a release helped | Name is web_vital . firstrun.metric is INP . average of firstrun.value . bucket by day |