Web Vitals algorithms
The rules of INP, CLS, LCP, FCP and TTFB in web-vitals 6.2.3, and how the page-view vitals of the library compare with them.
This note gives the rules of the web-vitals library, version 6.2.3 (released 2026-10-05, Apache-2.0). The research read the source code of the library on 2026-10-07. The rules include the restores from the back/forward cache, prerendering, soft navigations and the attribution build.
The last section compares web-vitals with PageViewVitals, the page-view vitals of @mark1russell7/lag. The library uses the same rules, with four main differences. It has a lower durationThreshold and a smaller INP attribution. It reports at checkpoints, and it records one histogram value for each page view and vital. The sources page lists all sources of this note.
Scope and browser support
The README gives a size of approximately 3K (brotli) for the standard build. The attribution build adds approximately 1.5K. It gives this browser support for each function:
| Function | Browsers |
|---|---|
onCLS() | Chromium |
onFCP(), onINP(), onLCP(), onTTFB() | Chromium, Firefox and Safari |
| Core Web Vitals for soft navigations | Chromium 151 and later |
These changes of the changelog affect the design of a monitor:
- 5.0.0 (2025-05-07). The library removed
onFIDand sorted the classes in selectors to decrease the cardinality. It added LoAF data to the INP attribution andgenerateTarget, and it started to usevisibility-stateentries. - 5.2.0 (2026-03-25). The library added
includeProcessedEventEntriesand limited the pending events to save memory. - 6.0.0 (2026-07-21). The library added soft navigations and a cap of 1 s on the idle callbacks. It also added the INP of small interactions after a restore from the back/forward cache.
includeProcessedEventEntriesbecame false by default. - 6.1.1. From this version, the interaction count of a navigation is a property of each
InteractionManagerinstance. - 6.2.0 to 6.2.3. The library fixed incorrect CLS reports after a restore, a negative
inputDelayand a negativeresourceLoadDuration. It also fixed two memory leaks: the pending LoAF entries and the clean-up of INP.
Shared mechanisms
Observers
The function observe() (observe.ts) makes each PerformanceObserver with buffered: true. It has these rules:
- It observes only the entry types that
PerformanceObserver.supportedEntryTypeslists. - It delays the callback by one microtask, because Safari started the callback at once instead of in a separate task.
- When it observes more than one entry type, it sorts the entries by
startTime + duration. The browser can deliver the entries of different types in a different sequence.
Idle or hidden
whenIdleOrHidden() (whenIdleOrHidden.ts) starts a callback at once if the page is hidden. Else it starts a requestIdleCallback with a timeout of 1000 ms, which races the next visibilitychange event. Without requestIdleCallback, it uses setTimeout with a delay of 0.
The first hidden time
The visibility watcher (getVisibilityWatcher.ts) gives the time at which the page became hidden for the first time:
- If the browser does not prerender the page at that time, the watcher uses a
visibility-stateentry. It takes the first entry with the namehiddenand astartTimeat or afteractivationStart. - Without such an entry, the value is 0 if the page is hidden at that time and the browser does not prerender it. Else the value is infinity.
- At the first
visibilitychangeto hidden, the value becomes thetimeStampof the event. - After a restore from the back/forward cache, the watcher calculates the value again in a new task, after the visibility state changes.
Reports
bindReporter() (bindReporter.ts) gives a metric to the callback only when all these conditions are true:
- The value is 0 or more.
- The report is forced, or the option
reportAllChangesis set. - The value changed after the last report (
deltais not 0), or this is the first report.
Without reportAllChanges, FCP and TTFB report one time, when their value is known. LCP reports when input or a hidden page finalizes it. CLS and INP report when the page becomes hidden, and at each soft navigation for the earlier URL. Thus each metric can report more than one time for one page load. The README tells the user to remove duplicates or add values with id and delta, and to send the queue when the page becomes hidden.
The metric object has name, value, rating, delta, id, entries, navigationType and navigationId. For soft navigations, it also has navigationInteractionId, navigationURL and navigationStartTime. The id has the form v6- + Date.now() + a random number of 13 digits.
Navigation types
initMetric() (initMetric.ts) selects the first applicable navigation type in this sequence:
- The type that the caller gives.
back-forward-cache, if a restore from the back/forward cache occurred.prerender, ifdocument.prerenderingis true oractivationStartis more than 0.restore, ifdocument.wasDiscardedis true.- The
typeof the navigation entry, with_changed to-:navigate,reloadorback-forward.
Steps 3 to 5 apply only when a valid navigation entry exists. Else the type is navigate.
A soft navigation gives the type soft-navigation. The function ignores a navigation entry whose responseStart is 0 or less, or is not less than performance.now() (getNavigationEntry.ts).
INP
The source is onINP.ts and InteractionManager.ts.
- Feature gate. The library measures INP only if
PerformanceEventTimingexists and its prototype hasinteractionId. Thus Firefox before 144 and Safari before 26.2 report no INP. - Observers. The library observes
eventandfirst-inputentries, andsoft-navigationentries when soft navigations are on. ThedurationThresholdis 40 ms by default. The README says that the browser default of 104 ms applies to the entries before the library starts. - Deferred processing. The library processes each batch in
whenIdleOrHidden(). Thus the browser has more time to dispatch all the entries between the interaction and the next paint. - Candidates. The library keeps the 10 longest interactions, sorted by latency. An entry without an
interactionId(a hover, or a tap that became a scroll) cannot be a candidate. An entry goes into the list in these cases:- Its interaction is already in the list.
- The list has fewer than 10 items.
- Its duration is more than the latency of the shortest of the 10.
- Latency of an interaction. The latency is the longest
durationof the entries of the interaction. A longer entry replaces the entries of the interaction. An entry with the same duration and the samestartTimeas the first entry joins the entries. - The p98 estimate. The index of the INP candidate is
min(list.length − 1, floor(interactionCount / 50)). With fewer than 50 interactions, INP is the worst interaction. With 50 to 99, it is the second worst. Because the list keeps 10 items, the index stops at the 10th worst for 500 interactions or more. - Interaction count. The library uses
performance.interactionCountwhere the browser has it: Chrome and Edge 144, Firefox 144 and Safari 26.2. Else a polyfill observesevententries withdurationThreshold: 0, which the specification clamps to 16 ms. The polyfill calculates(maxInteractionId − minInteractionId) / 7 + 1, thus it is correct only if Chrome increases the IDs by 7 (interactionCountPolyfill.ts). - Count for a navigation. The count for the current navigation is the total count minus the count at the last reset. A restore from the back/forward cache and each soft navigation reset the count. Before the first reset, the count at the last reset is 0. Thus the load counts the interactions from the start of the page, also when the library starts later.
- First input. The browser gives
first-inputentries for all durations. They go through the same processing, thus they must have aninteractionId, and their fulldurationbecomes the latency. The JSDoc says that the library falls back to the input delay of the first interaction, but the code uses the duration. Thus a page whose interactions are all shorter than 40 ms still reports a small INP. The metric starts at −1, thus a latency of 0 is a change too, and the library reports an INP of 0. - Synthetic INP. After a restore from the back/forward cache or a soft navigation, no
first-inputentry comes. If the count increased but no candidate exists, INP is 8 ms, with no entries. - Interaction type. The browser groups the entries of one interaction with one
interactionId:pointerdown,pointerupandclickfor a pointer, andkeydownandkeyupfor a keyboard. The attribution giveskeyboardif the name of the first entry starts withkey, andpointerin all other cases. - Reports. After each batch, INP updates when the p98 latency changed. When the page becomes hidden, the library takes the pending records and forces a report. At a soft navigation, it forces a report of the earlier navigation and then starts a new metric. The entries after the
soft-navigationentry in the sorted batch go to the new metric. INP continues to collect after the page becomes visible again.
CLS
The source is onCLS.ts and LayoutShiftManager.ts.
- FCP first.
onCLS()starts only in the callback ofonFCP(), as CrUX does. FCP reports only if the first contentful paint occurred before the page became hidden. - Input. The library ignores each entry with
hadRecentInput. - Session windows. An entry joins the current session if it starts less than 1000 ms after the last entry and less than 5000 ms after the first entry. Else it starts a new session.
- Value. CLS is the largest session value. The entries of the metric are the entries of that session.
- Reports. The library reports after each batch, but the callback starts only with
reportAllChanges. AsetTimeoutimmediately after FCP gives an early 0 toreportAllChangesusers. When the page becomes hidden, the library takes the pending records and forces a report. - Restores and soft navigations. A restore from the back/forward cache starts a new metric at 0, and reports it after two animation frames. A soft navigation forces a report of the earlier navigation and starts again at 0.
Layout Instability exists only in Chromium, thus CLS is a Chromium metric. In Firefox and Safari, observe() makes no observer, and onCLS() reports nothing, also after a restore.
LCP
The source is onLCP.ts.
- Activation. On a prerendered page, the library waits for
prerenderingchangebefore it starts (whenActivated.ts). - Entries. Without
reportAllChangesand without soft navigations, the library uses only the last entry of each batch. - Value. The value is
max(entry.startTime − activationStart, 0). ThestartTimeof an LCP entry is itsrenderTime, or itsloadTimewhen therenderTimeis 0. From Chrome 133, a cross-origin image withoutTiming-Allow-Originhas a coarsenedrenderTimeinstead of 0. - Hidden pages. An entry counts only if its time is less than the first hidden time.
- Finalization. Capture listeners on
keydown,clickandvisibilitychangefinalize LCP, but only for trusted events (from 5.1.0). The first such event schedules a function withwhenIdleOrHidden(). Without soft navigations, that function disconnects the observer and removes the listeners. In all cases, it forces a report. - Scroll. The library does not use scroll for the finalization, because a script can make a scroll. The source refers to issue #75.
- Restores. After a restore from the back/forward cache, the value is
performance.now()minus thetimeStampofpageshow, after two animation frames. - Soft navigations. The library uses
interaction-contentful-paintentries whoseinteractionIdis theinteractionIdof the navigation. The value is therenderTimeof the nested LCP entry minus thestartTimeof the ICP entry. At the start of a soft navigation, the library processes the result ofgetLargestInteractionContentfulPaint(), which can be null. It also resets the first hidden time.
FCP
The source is onFCP.ts.
- The library uses the first
paintentry with the namefirst-contentful-paint, and then disconnects the observer. - If the entry is before the first hidden time, the value is
max(startTime − activationStart, 0), and the library reports it at once. - After a restore from the back/forward cache, the value is
performance.now()minus thetimeStampofpageshow, after two animation frames. - For a soft navigation, the value is
(presentationTime || paintTime || 0) − startTimeof thesoft-navigationentry, clamped at 0.
TTFB
The source is onTTFB.ts.
- The library waits for the activation of a prerendered page and then for the
loadevent. Then it waits for one more task, so thatloadEventEndhas a value. - The value is
max(responseStart − activationStart, 0). - The library ignores a navigation entry with an incorrect
responseStart(refer to navigation types). - After a restore from the back/forward cache, TTFB is 0. The library reports it only if the first load had a navigation entry.
- For a soft navigation, TTFB is 0, and the
soft-navigationentry is the entry of the metric.
Back/forward cache restores
A capture listener for pageshow with persisted true finds each restore (bfcache.ts). It stores the timeStamp of the event as the restore time, which becomes navigationStartTime. Then each metric starts a new metric with a new id and the type back-forward-cache:
| Metric | After a restore |
|---|---|
| INP | The library clears the interactions, and the count starts again. |
| CLS | The value starts again at 0. |
| FCP and LCP | The value is the time from pageshow to the second animation frame. |
| TTFB | The value is 0. |
The visibility watcher calculates the first hidden time again in a new task, so that visibilityState has time to change.
Prerender
INP, LCP, FCP and TTFB wait for the activation with whenActivated(). CLS waits for FCP. All load metrics subtract activationStart. The visibility watcher ignores the hidden state of a prerendering document, and examines the state again at prerenderingchange.
Soft navigations
Chrome 151 (stable on 2026-07-28) enabled the soft-navigation and interaction-contentful-paint entry types by default (Chrome). For a soft navigation, three things are necessary: an action of the user, a visible change of the URL and a visible paint. The PerformanceSoftNavigation entry gives navigationId, interactionId, name (the new URL), startTime (the interaction), paintTime and presentationTime (its FCP), and getLargestInteractionContentfulPaint().
The web-vitals library supports soft navigations from version 6.0.0, with the option reportSoftNavs: true. checkSoftNavsEnabled() turns them on only in these conditions (softNavs.ts):
supportedEntryTypeslistssoft-navigation. This also guards against the preference of Firefox that disables the observer.PerformanceSoftNavigation.prototype.getLargestInteractionContentfulPaintis a function. The library supports only this method, because the stable version shipped it. Earlier implementations had an attribute.- The option
reportSoftNavsis true.
For each soft navigation, TTFB is 0, and INP and CLS start again. FCP and LCP are the first and the largest paints after the navigation. The README says that an element that stays on the page does not count, because the browser does not paint it again. In browsers without soft navigations, the option has no effect.
The attribution build
INP attribution
The source is attribution/onINP.ts.
- Frame groups. The library groups all
evententries by their render time,startTime + duration, also the entries without aninteractionId. Two entries are in the same group if their render times are 8 ms or less apart. Each group keeps the smalleststartTime, the smallestprocessingStartand the largestprocessingEnd. - Memory. The library keeps the groups of the 10 candidates and the last 10 frames (
MAX_PENDING_FRAMES). It cleans the groups in idle time. - LoAF. The library observes
long-animation-frameentries. It keeps the entries that overlap a kept group, and the recent entries that start after the latestprocessingEnd. - Target. When an interaction becomes a candidate, the library makes a selector from the first entry with a
target, throughgenerateTargetorgetSelector. Else it uses thetargetSelectorof an entry. - Phases.
processingStartismax(group.processingStart, interactionTime).nextPaintTimeismax(startTime + duration, processingStart).processingEndismin(group.processingEnd, nextPaintTime), which caps a synchronous dialog, for examplealert(). - Phase durations.
inputDelayisprocessingStart − interactionTime,processingDurationisprocessingEnd − processingStart, andpresentationDelayisnextPaintTime − processingEnd. - LoAF fields.
longAnimationFrameEntrieshas the LoAF entries that overlap the interaction.longestScriptgives the longest script and its phase.totalScriptDuration,totalStyleAndLayoutDuration,totalPaintDurationandtotalUnattributedDurationdivide the time. LoAF attributes only scripts of more than 5 ms, in frames of more than 50 ms. - Synthetic INP. For an INP without entries,
inputDelayandprocessingDurationare 0, andpresentationDelayis the value.
Other attribution
- LCP (
attribution/onLCP.ts) givestarget,url,timeToFirstByte,resourceLoadDelay,resourceLoadDuration,elementRenderDelayand the entries. The four sub-parts add up to LCP. The library finds the resource of the LCP element in a buffer of the last 50resourceentries, and then in the performance timeline. - CLS (
attribution/onCLS.ts) gives the largest shift of the session and its first source with an element node. It also gives the selector of that node, and the time and the value of the shift. The library makes the selector while the node exists. - FCP gives
timeToFirstByte,firstByteToFCPandloadState. - TTFB gives
waitingDuration,cacheDuration,dnsDuration,connectionDurationandrequestDuration.
Selectors
getSelector() (getSelector.ts) goes from the node up to the document. At each element, it writes #id and stops, or it writes the tag name and the sorted classes. A node that is not an element gives its upper-case nodeName without #. The parts join with >. The selector stops before it becomes longer than 100 characters.
Rating thresholds
| Metric | good | needs-improvement | poor |
|---|---|---|---|
| INP | 200 ms or less | 500 ms or less | More than 500 ms |
| LCP | 2500 ms or less | 4000 ms or less | More than 4000 ms |
| CLS | 0.1 or less | 0.25 or less | More than 0.25 |
| FCP | 1800 ms or less | 3000 ms or less | More than 3000 ms |
| TTFB | 800 ms or less | 1800 ms or less | More than 1800 ms |
Comparison with PageViewVitals
PageViewVitals (vitals/PageViewVitals.ts) and ViewCollector (vitals/ViewCollector.ts) measure the vitals of each page view. A page view starts at the load of the page or at a restore from the back/forward cache. When the option softNavigations is true, a soft navigation also starts a page view. The default is false, so that the values agree with CrUX. The page page-view vitals describes the monitor and its metrics.
The browser test web-vitals-oracle.test.ts operates web-vitals 6.2.3 and PageViewVitals on the same page, in Chromium, Firefox, WebKit and Chrome. It gives web-vitals a durationThreshold of 16 ms. The test expects exactly the same FCP, LCP, TTFB and INP from the two libraries, and the same three phases of the INP attribution. It expects the same CLS where the browser has layout-shift entries, and no CLS from the two libraries elsewhere (testing strategy).
A CI job also operates the test in Safari 26.6.2 on a GitHub macOS runner. In the log of that job, the two libraries agree exactly: FCP 400 ms, LCP 400 ms, TTFB 22 ms and INP 184 ms. The three INP phases are 0 ms, 180 ms and 4 ms in both libraries. Safari has no layout-shift entries, thus neither library gives a CLS.
The same rules
- INP.
InpCalculatorkeeps the 10 longest interactions, uses the longest entry of each interaction, and takes the indexmin(length − 1, floor(count / 50)). The count of the load starts at the start of the page, and the count of a later page view starts at its start. Entries without aninteractionIddo not count. Thefirst-inputentry is a candidate, and a candidate of 0 ms gives an INP of 0. After a restore or a soft navigation, an INP of 8 ms (SHORT_INTERACTION_ESTIMATE_MS) replaces a missing candidate. - INP attribution.
interactionAttributionuses the frame groups of web-vitals. A group has allevententries whose render times are 8 ms or less from the first entry of the group. These include the entries of other interactions and the entries without aninteractionId. The phases usemax(group.processingStart, interactionTime),max(startTime + duration, processingStart)andmin(group.processingEnd, nextPaintTime). - CLS.
ClsCalculatorstarts a new session window at a gap of 1000 ms or more, or at a length of 5000 ms or more. It ignores shifts withhadRecentInput. For a load, CLS counts only after FCP. - CLS target. The target is the first source of the largest shift with an element node, or else the first source.
ViewCollectormakes the selector at the time of the shift, while the node exists. - LCP and FCP. The values subtract
activationStartand are clamped at 0. An entry counts only before the first hidden time. Only the first page view takes FCP and LCP from thepaintandlargest-contentful-paintentries. The first trustedkeydownorclickmakes LCP final, through a capture listener on the window, as in web-vitals. - Vitals without an entry type. A vital whose entry type the browser does not list gets no value, also after a restore. INP needs
event, CLS needslayout-shift, LCP needslargest-contentful-paintand FCP needspaint. Thus Firefox and Safari give no CLS. - TTFB. The value is
responseStart − activationStart, clamped at 0. The page source ignores a navigation entry whoseresponseStartis 0 or less, or is not less thanperformance.now(). A restore and a soft navigation give 0, when the load had a navigation entry. - Restores. FCP and LCP of a restored view are the time from
pageshowto the second animation frame. - Prerender. The measurement starts at the activation, and the first view gets the type
prerender. - Navigation types. The first view uses the sequence
prerender,restore, then the type of the navigation entry. - Soft navigations. FCP is
(presentationTime || paintTime || 0) − startTime. LCP is therenderTimeof the nested LCP entry minus thestartTimeof the ICP entry. ICP entries of other interactions do not count. The new page view processesgetLargestInteractionContentfulPaint()at its start, before theinteraction-contentful-paintentries that wait. The INP of the new page view counts only the interactions after the soft navigation, as the README of web-vitals says. - Thresholds and selectors.
VITAL_THRESHOLDSandrateVitaluse the same limits.describeNodemakes the same selectors asgetSelector, with sorted classes and a maximum of 100 characters.
The differences
| Topic | web-vitals 6.2.3 | PageViewVitals |
|---|---|---|
durationThreshold of event entries | 40 ms by default | 16 ms, the minimum that browsers permit |
| INP attribution | The frame group of all event entries, also entries of other interactions, plus the LoAF fields | The same frame groups, from the event entries of 16 ms or more, with no LoAF fields |
| Time of the reports | Callbacks at each finalization, and at each change with reportAllChanges | Reports at checkpoints: the page becomes hidden, a new page view starts, stop() or flush() |
| Metrics | One metric object for each report | One histogram value for each page view and vital, and a browser.web_vital event for each change |
The details of the differences:
- Threshold. The lower threshold also gives the collector the interactions from 16 ms to 40 ms. The entries from before the start still have the browser default of 104 ms.
- INP attribution.
interactionAttributiontakes the longest entry of the interaction and the frame group of that entry. It gives the target, the type and the three phases. The collector observes entries of 16 ms or more, thus a frame group can contain shorter events than in web-vitals. The attribution has no LoAF fields. - Checkpoints. At each delivery of the browser and before each checkpoint,
PageViewVitalsalso takes the entries that the other observers did not deliver yet. The next list gives the order of the entries. After the final checkpoint of a page view, no report for it comes. - One histogram value. The histogram of a vital gets the value of the first report of each page view. Usually, that report comes when the page becomes hidden for the first time. A histogram cannot remove a value.
- Later changes. A later change goes only into a
browser.web_vitalevent with adelta. An example is a higher INP after the user comes back to the tab. The latest event for eachbrowser.web_vital.idhas the final value.
The code also has these smaller differences:
- Interaction count. Without
performance.interactionCount,InpCalculatorcounts the interactions that it saw. The web-vitals library estimates the count from the range of the IDs, divided by 7. The count of the library is a lower bound, because fast interactions give no entry. - Interaction type. The library gives
pointer,keyboardorother. The web-vitals library giveskeyboardorpointer. - The order of the entries. The web-vitals library sorts the entries of one delivery by
startTime + duration, when one observer observes more than one type.PageViewVitalsputs the entries of all observers into one sequence, bystartTime. It keeps the sequence of the browser in the list of each observer. At equal start times, the observer that it made first comes first:event,first-input,layout-shift,paint,largest-contentful-paint,soft-navigation, theninteraction-contentful-paint. - The boundary of a soft navigation. The web-vitals library gives the entries after the
soft-navigationentry in one sorted delivery to the new metric.PageViewVitalsdivides the entries that wait when it processes thesoft-navigationentry. An entry after the start of the navigation goes to the new page view, also when its observer did not deliver it yet. As in web-vitals, an entry that the browser delivered before thesoft-navigationentry stays in the earlier page view, also if it starts later. The new page view ignores the entries of the interaction that caused the navigation (itsinteractionId), also when the browser delivers them after thesoft-navigationentry. - LCP finalization. The web-vitals library finalizes LCP at the first trusted
keydown,clickorvisibilitychangeevent, in an idle callback.PageViewVitalslistens to the samekeydownandclickevents. It makes the LCP final at the time of the event, not in an idle callback. Then a candidate that renders after that time does not count. The first hidden time stops the paints instead ofvisibilitychange. - Frame memory.
ViewCollectorlooks for the frame group of a new entry in the last 50 groups. The web-vitals library keeps the last 10 groups (MAX_PENDING_FRAMES) and the groups of its 10 candidates. - Attribution of LCP and CLS. The LCP attribution has the target and the URL only, with no sub-parts. The CLS attribution has the selector of the largest shift only.
- TTFB. The library reads the navigation entry when the measurement starts, and does not wait for the
loadevent. - Soft navigations. The library turns them on when
supportedEntryTypeslistssoft-navigationandinteraction-contentful-paint. It does not examine the prototype ofPerformanceSoftNavigation.
The events carry the attribute names of the OpenTelemetry semantic conventions v1.44, for example browser.web_vital.name and browser.web_vital.delta (instrumented/page-view-vitals.ts). The note OpenTelemetry metrics in the browser gives the conventions.