From FID to INP on elangodev.com
When I tuned Core Web Vitals on elangodev.com — especially blog and handbook pages and blog pages running Framer Motion — INP replaced FID as the metric that actually exposed jank. I use scheduler.postTask and idle callbacks in AnalyticsTracker.jsx on this production app to keep interaction paths under budget. This article reflects what I measured on my portfolio, not generic lab scores.
For years, frontend developers optimized for First Input Delay (FID), a metric that only measured the time between a user first interacting with a page and when the browser was able to begin processing event handlers. This metric was easily tricked by minimal event handlers that deferred work. However, the introduction of Interaction to Next Paint (INP) changed the landscape completely. INP measures the entire latency of all user interactions throughout the lifecycle of a page, accounting not just for the delay in starting execution, but also for the execution duration itself and the time required for the browser to paint the updated frame.
Optimizing for INP demands a radical change in architectural design. It is no longer enough to avoid blockages during page load; we must ensure that the main thread is never occupied for more than 50 milliseconds at any point in the application lifecycle. To achieve this, modern full-stack and frontend engineers must master cooperative multitasking, advanced execution profiling, and off-main-thread offloading.
Tracking Bottlenecks with the Long Animation Frames (LoAF) API
Historically, developers relied on the Long Tasks API to identify main-thread blocking operations. While useful, the Long Tasks API had a fatal flaw: it only reported that a task took longer than 50ms, leaving engineers in the dark about which script, package, or event handler initiated the work.
The newer Long Animation Frames (LoAF) API solves this telemetry problem. It group scripts together that contribute to a delayed frame, providing unmatched granularity. LoAF reveals the source URL, character position, invoker type (e.g., event handler, Promise, setTimeout), and the exact execution time of each script block contributing to the delay.
Here is how you can programmatically implement LoAF telemetry to detect and report slow execution paths directly to your analytics engine:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`[LoAF detected] Duration: ${entry.duration}ms`);
entry.scripts.forEach((script) => {
console.group(`Script attribution: ${script.invokerType}`);
console.log(`Source: ${script.sourceURL || 'Inline Script'}`);
console.log(`Function: ${script.sourceFunctionName || 'Anonymous'}`);
console.log(`Duration: ${script.duration}ms`);
console.log(`Execution Start: ${script.executionStart}`);
console.groupEnd();
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });With LoAF, you can pinpoint the exact third-party dependency or heavy component re-render causing frame drops, allowing for precise, evidence-based performance refactoring.
Cooperative Multitasking via Scheduler.yield() and postTask()
When executing long-running computational jobs (such as parsing large JSON streams, processing tables, or generating PDFs), the main thread remains occupied, completely freezing user input handlers. The traditional mechanism to handle this was chunking with setTimeout(fn, 0). However, this pattern introduces a minimum of 4ms of delay, degrades prioritizing order, and yields control back to the event loop entirely, allowing other non-essential tasks to jump ahead of high-priority user actions.
The modern solution is the Prioritized Task Scheduling API. This API exposes scheduler.postTask() and scheduler.yield(), allowing developers to break up large chunks of synchronous operations while preserving execution context and task priority.
Implementing scheduler.yield()
Unlike setTimeout, scheduler.yield() pauses the current execution sequence and yields control specifically to allow paint updates and urgent user input. Once those high-priority tasks are resolved, execution resumes immediately, preventing unnecessary delay.
async function processLargeBatchOfData(items) {
let lastYieldTime = performance.now();
for (let i = 0; i < items.length; i++) {
// Execute work
processSingleItem(items[i]);
// Yield if we have been blocking for over 16ms (1 target frame duration)
if (performance.now() - lastYieldTime > 16) {
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
} else {
// Fallback for older browsers
await new Promise((resolve) => setTimeout(resolve, 0));
}
lastYieldTime = performance.now();
}
}
}By yielding dynamically based on execution time, we maintain smooth UI transitions and near-zero interaction delay even while running intense background operations on the main thread.
Off-Main-Thread Architecture: Web Workers & Comlink
The absolute best way to optimize the main thread is to ensure your computational logic never runs on it in the first place. Web Workers provide a distinct execution thread running in parallel to the main thread, with no access to the DOM or window object, making them perfect for non-UI tasks.
Unfortunately, the native Web Worker API relies on structured cloning via postMessage and manual event listening, which quickly introduces spaghetti code. To overcome this, we can utilize Comlink, an elegant RPC (Remote Procedure Call) abstraction by Google Chrome Labs that makes asynchronous worker communication feel like native module imports.
The Web Worker Code (worker.js)
import * as Comlink from 'comlink';
const heavyComputationLibrary = {
runSearchFilter(dataset, query) {
// CPU intensive search, sorting, and regex matching
return dataset.filter(item => item.name.toLowerCase().includes(query.toLowerCase()));
},
generateDataReport(data) {
// Complex analytics calculation
return data.reduce((acc, current) => {
// calculations...
return acc;
}, {});
}
};
Comlink.expose(heavyComputationLibrary);The Main Thread Orchestrator
import * as Comlink from 'comlink';
async function initWorker() {
const rawWorker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const searchWorker = Comlink.wrap(rawWorker);
const largeDataset = [...]; // Array of thousands of elements
// Execute asynchronously off the main thread
const results = await searchWorker.runSearchFilter(largeDataset, 'performance');
console.log('Search completed without blocking the main thread:', results);
}
initWorker();By utilizing Comlink-powered Web Workers, calculations that would normally trigger persistent long tasks are kept entirely separate from the rendering loop, keeping your page exceptionally interactive and achieving flawless INP scores.
Fine-tuning Rendering and GPU Layer Allocation
Even with highly optimized JavaScript, layout and paint operations can still cause frame drops. If a DOM manipulation forces the browser to re-run layout calculation (Reflow), the main thread will block. To avoid this, optimizations must extend into the CSS rendering pipeline.
Keep transitions and animations confined to Composite-only properties: transform, opacity, and filter. These properties bypass the Layout and Paint stages completely and are executed directly on the GPU.
Using the CSS property will-change instructs the browser ahead of time that an element will modify its transform or opacity, allowing the browser to promote that element to its own composite layer on the GPU. However, avoid global layer promotion:
How I apply this on elangodev.com
Analytics tracking yields via idle callbacks, heavy formatting stays off the critical path, and Framer Motion sections avoid layout-thrashing properties. I re-check LoAF after shipping large blog rewrites because long HTML hydrate paths can reintroduce main-thread pressure even when JavaScript looks “optimized.”
INP wins on this site came from architecture — yielding, deferring non-essential work, and measuring real interactions — not from chasing micro-benchmarks in isolation.




