Patterns for improving INP

Just a guy who loves to write code and watch anime.
Preface
This blog post is quite a quick one. It serves as a reference for myself. See it as my notes.
Introduction to INP
I assume you're not familiar with INP. I recommend giving this a read: Interaction to Next Paint (INP).
To summarize:
INP (Interaction to Next Paint) measures how long it takes from user input until the browser can paint the next frame.
It observes click, tap, and keyboard events throughout the page lifecycle and reports the worst-performing ones (excluding outliers).
Think of it as "how long until the user sees visual feedback from their action."
Understanding the problem
A lot of the times when the user interacts with the site, we want to know what they're doing.
So for each interaction, we track analytics.
Let's say we have a copy button:
const handleCopy = async () => {
await navigator.clipboard.writeText(textToCopy);
// Track analytics
trackAnalytics("copy_clicked");
setIsCopied(true);
};
It may look something like this. Now, this isn't good. Because tracking analytics is a blocking operation. We don't need to wait for it to finish before we can show the user that the copy was successful.
Why?
Becuase it has nothing to do with whether the user's interaction was successful or not.
Patterns for not blocking the main thread
Let's go over a few patterns for not blocking the main thread.
I'll explain each pattern and how it works at a high level. My goal isn't to break down every bit of the APIs. If you want that level of detail, MDN is a good place. But I would say the goal is to learn the patterns and know when to reach for them.
Basic yielding pattern
const handleCopy = async () => {
// Do the important UI work first
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true); // Update UI immediately
// Yield before analytics
await yieldToMain();
// Now do analytics in next task
trackAnalytics("copy_clicked");
};
The goal with this pattern is to give the browser a chance to do other important work. When done with them, we can then do analytics.
await yieldToMain() is like a pause. Allowing the browser to do other work. Essentially allowing the browser to add things onto the call stack (main thread) and do new work. While the resume is queued.
How yieldToMain looks under the hood:
async function yieldToMain(options = { priority: "user-visible" }) {
// 1. Try modern scheduler API first
if ("scheduler" in window && "yield" in scheduler) {
return scheduler.yield(options);
}
// 2. Try postTask API
if ("scheduler" in window && "postTask" in scheduler) {
return scheduler.postTask(() => {}, options);
}
// 3. Fallback to setTimeout
return new Promise((resolve) => setTimeout(resolve, 0));
}
It uses the new scheduler API.
There is a bit to unpack here.
scheduler.yield is special. It's part of the browser's task scheduling system and works directly with the browser's task queue. It can understand priorities and make smarter scheduling decisions.
It can take different priority options:
user-blocking: Critical UI updates (like input response)user-visible: Default, normal UI workbackground: Non-critical stuff (analytics, logging)
An example:
// Using scheduler.yield:
scheduler.yield({ priority: "background" });
// Browser understands: "This can wait if there's more important work"
yield is likely what you want most of the time. postTask is when you want even more control.
If you need:
Explicit priority control
Ability to abort tasks
Delay scheduling
Signal handling
Then go for postTask. A lot of the times you're not gonna need it. You can read more about it here.
What's interesting with postTask is that you can implement similar behavior to the React hook useDeferredValue.
"How does that last fallback work?"
return new Promise(resolve => {
setTimeout(resolve, 0);
});
// What happens:
1. Promise created
2. setTimeout gets pushed to macrotask queue
3. Original code yields back to browser
4. Browser can do other work (paint, handle inputs, etc)
5. When macrotask queue is empty, setTimeout callback runs
6. resolve() is called, which resolves the Promise
7. Code after 'await' can continue
The key insight is that setTimeout moving to the macrotask queue is what creates that gap where the browser can do other work. It's what creates the "pause" in the code.
A more complete pattern with a queue
// Create an analytics queue
class AnalyticsQueue {
private queue: Array<() => void> = [];
private isProcessing = false;
async add(event: () => void) {
this.queue.push(event);
if (!this.isProcessing) {
await this.process();
}
}
private async process() {
this.isProcessing = true;
while (this.queue.length > 0) {
await yieldToMain(); // Yield before each analytics call
const event = this.queue.shift();
if (event) event();
}
this.isProcessing = false;
}
}
const analyticsQueue = new AnalyticsQueue();
// Usage in component
const handleCopy = async () => {
// Do important UI work first
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
// Queue analytics for later
analyticsQueue.add(() => trackAnalytics('copy_clicked'));
};
The idea here is to queue analytics calls. Every time we queue an anaytics call, we only try to process it if we're not already processing.
When we do process it, to avoid blocking the main thread for too long (imagine we have 10 calls queued up), we yield to the main thread inbetween each call.
This pattern is quite nice at scale where you really care about performance because you can just add it to the queue in the end and it doesn't look too ugly where you need to call await yieldToMain() every time befoe tracking analytics (which you'll do quite often).
requestIdleCallback
This is a browser API that allows you to run code when the browser is idle.
This works on both chrome and firefox, not safari though. See docs here.
const handleCopy = async () => {
// UI work first
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
// Do analytics when browser is idle
requestIdleCallback(() => {
trackAnalytics("copy_clicked");
});
};
This is good for truly background work. But honestly, the scheduler API is designed to help you deal with the scheduling of tasks.






