Skip to content

Instantly share code, notes, and snippets.

@mmocny

mmocny/getINP.js Secret

Last active February 20, 2023 13:30
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mmocny/235399a5da32aede1f69489145a647bb to your computer and use it in GitHub Desktop.
Save mmocny/235399a5da32aede1f69489145a647bb to your computer and use it in GitHub Desktop.
/*
* A snippet for measuing Interaction to Next Paint (INP).
* Michal Mocny (mmocny@google.com).
*
* Using Event Timing API, and using ideas from recent developments towards a new responsiveness metric.
* See: https://web.dev/responsiveness
*
* This snippet can be used to measure all interactions with the page, measure the total "Interaction to Next Paint" time
* for each, then, because we are measuring all interactions, record the ~98th percentile worst interaction overall.
* We found this works better than recording just the single worst interaction for sites with over 50 interactons.
*/
const EVERY_N = 50;
const MAX_ENTRIES = 10;
const largestINPEntries = [];
let minKnownInteractionId = Number.POSITIVE_INFINITY;
let maxKnownInteractionId = 0;
function addInteractionEntryToINPList(entry) {
// Add this entry only if its larger than what we already know about.
if (largestINPEntries.length < MAX_ENTRIES || entry.duration > largestINPEntries[largestINPEntries.length-1].duration) {
// If we already have an interaction with this same ID, replace it rather than append it.
let existing = largestINPEntries.findIndex((other) => entry.interactionId == other.interactionId);
if (existing >= 0) {
// Only replace if this one is actually longer
if (entry.duration > largestINPEntries[existing].duration) {
largestINPEntries[existing] = entry;
}
} else {
largestINPEntries.push(entry);
}
largestINPEntries.sort((a,b) => b.duration - a.duration);
largestINPEntries.splice(MAX_ENTRIES);
}
}
function getCurrentINPEntry() {
const interactionCount = estimateInteractionCount();
const which = Math.min(largestINPEntries.length-1, Math.floor(interactionCount / EVERY_N));
return largestINPEntries[which];
}
function updateInteractionIds(interactionId) {
minKnownInteractionId = Math.min(minKnownInteractionId, interactionId);
maxKnownInteractionId = Math.max(maxKnownInteractionId, interactionId);
}
function estimateInteractionCount() {
// const drag = performance.eventCounts.get('dragstart');
// const tap = performance.eventCounts.get('pointerup');
// const keyboard = performance.eventCounts.get('keydown');
// // This estimate does well on desktop, poorly on mobile (due to crbug.com/1157118 ?)
// return tap + drag + keyboard;
// This works well when PO buffering works well
return (maxKnownInteractionId > 0) ? ((maxKnownInteractionId - minKnownInteractionId) / 7) + 1 : 0;
}
function trackInteractions(callback) {
const observer = new PerformanceObserver(list => {
for (let entry of list.getEntries()) {
if (!entry.interactionId) continue;
updateInteractionIds(entry.interactionId);
addInteractionEntryToINPList(entry);
callback(entry);
}
});
observer.observe({
type: "event",
durationThreshold: 16, // minumum by spec
buffered: true
});
}
// Will get called multuple times, every time INP changes
function getINP(callback) {
let previousINP;
trackInteractions(entry => {
const inpEntry = getCurrentINPEntry();
if (!previousINP || previousINP.duration != inpEntry.duration) {
previousINP = inpEntry;
callback({
value: inpEntry.duration,
entries: [inpEntry],
interactionCount: estimateInteractionCount(),
});
}
});
}
// Alternative to getINP
// Will get called multuple times, for every new interaction
function reportAllInteractions(callback) {
trackInteractions(entry => {
callback({
value: entry.duration,
entries: [entry],
interactionCount: estimateInteractionCount(),
});
});
}
/* Usage Example */
getINP(({ value, entries, interactionCount }) => {
console.log(`[INP] value: ${value}, interactionCount:${interactionCount}`, entries);
let currentINP = entries[0];
// RenderTime is an estimate, because duration is rounded, and may get rounded keydown
// In rare cases it can be less than processingEnd and that breaks performance.measure().
// Lets make sure its at least 4ms in those cases so you can just barely see it.
const presentationTime = currentINP.startTime + currentINP.duration;
const adjustedPresentationTime = Math.max(currentINP.processingEnd + 4, presentationTime);
// Thanks philipwalton!
// Add measures so you can see the breakdown in the DevTools performance panel.
performance.measure(`INP.duration [${currentINP.name}]`, {
start: currentINP.startTime,
end: adjustedPresentationTime,
});
performance.measure(`INP.inputDelay`, {
start: currentINP.startTime,
end: currentINP.processingStart,
});
performance.measure(`INP.processingTime`, {
start: currentINP.processingStart,
end: currentINP.processingEnd,
});
performance.measure(`INP.presentationDelay`, {
start: currentINP.processingEnd,
end: adjustedPresentationTime,
});
});
let maxDuration = 0;
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
// Comment this out to show ALL event entry types (useful e.g. on Firefox).
if (!entry.interactionId) continue;
if (entry.duration > maxDuration) {
// New longest Interaction to Next Paint (duration), ouch!
maxDuration = entry.duration;
console.log(`[INP] duration: ${entry.duration}, type: ${entry.name}`, entry);
} else {
// Not the longest Interaction. May still want to log it to see everything.
// console.log(`[Interaction] duration: ${entry.duration}, type: ${entry.name}`, entry);
}
}
}).observe({
type: "event",
durationThreshold: 16, // minimum supported by spec
buffered: true
});
@paulirish
Copy link

With the performance.measure addition you can better visualize these components:
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment