Redbus, INP, and the Magic of requestIdleCallback

Harsh Choudhary
redbus India Blog
7 min readMar 18, 2024

--

You know that feeling when you’re trying to book a bus ticket, and the website just…hangs? It’s like waiting for a bus in the rain — frustrating. That’s why we at Redbus decided to take a hard look at our website’s speed, specifically a metric called Interaction to Next Paint (INP).

INP is basically how fast your website reacts when you click something. Google thinks it’s super important (and they’re right), so it affects where you show up in search results. But more than that, a slow website is just plain annoying for users. Nobody wants to wait for a website to catch up with their clicks!

As the industry leaders in the segment, we’re driven to be the best in every aspect of the travel experience not just for our selection of routes and prices, but for the whole experience. Unfortunately, our INP score wasn’t cutting it. Sitting at 351, it meant our site felt sluggish, especially when people were trying to book. We weren’t just worried about Google. We wanted our users to have the fast, smooth experience they deserved, especially when they’re making important travel decisions.

After digging into the problem, we found a surprising culprit: analytics. Don’t get me wrong, things like Google Analytics and our own MRI events are super useful for figuring out how people use the site. But, it turns out all that tracking code was seriously slowing things down. It was like trying to run a marathon with a backpack full of rocks!

The Culprit Lurking in the Code

1.1 Behind-the-scenes look: User actions travel through delays before updates appear

Imagine your website is a restaurant, and the main thread is your waiter. INP is like how quickly the waiter responds when you raise your hand to order, ask for the check, or need something else. Now, picture the waiter trying to take orders while also juggling a stack of dishes to deliver to other tables. Every dish they carry takes time and attention away from interacting with new customers.

The same thing happens with analytics tasks and your main thread. While analytics are important, they can hog the waiter’s attention. The longer it takes the waiter to handle those dishes (those analytics tasks), the slower they’ll be at responding to you, the customer (which is what affects INP). Those delays make the entire dining experience feel less responsive, even if you just need a quick refill.

What if the waiter first attends to customer requests and then handles the dishes when they are idle? This approach would ensure high responsiveness for the customers while also ensuring that tasks are completed promptly when needed.

The `idle` solution:

This can be achieved by deferring non-critical tasks, such as analytics code execution, when the main thread is idle. This ensures responsiveness to primary tasks, creating a snappier experience for users and simultaneously reducing INP scores.

To aid in our implementation, we utilize requestIdleCallback.

The requestIdleCallback() method lets you schedule tasks to run when the browser has some spare time. This is great for background work that shouldn’t slow down things like animations or responding to user input. Usually, tasks run in the order they’re scheduled, but if you set a timeout, it will run sooner if needed to avoid missing the deadline.

requestIdleCallback(callback,options) // options currently accepts only timeout

The downside of requestIdleCallback() is that there's no guarantee your code will actually run. This can be a problem if you absolutely need certain data, like analytics for important decisions. Often, developers prefer to keep code synchronous (even though it can slow things down) to make sure it executes reliably.

Analytics code is a perfect example of why requestIdleCallback() can be tricky. Often, we need analytics data even when the user closes the page (e.g. tracking outbound link clicks, etc.). Since requestIdleCallback() might not run in those situations, analytics libraries often run their code synchronously for safety, even though it can slow down the user experience.

However, the `idle-until-urgent` pattern offers a solution. We can ensure the queue runs right before the page unloads. The visibilitychange event (especially when the page becomes ‘hidden’) is a reliable signal that the page might be closing. Since the user isn’t interacting with a hidden page, this is a perfect moment to run any queued idle tasks.

import { handleLayerCall } from './layerHandler';
import * as dataLayer from './genericEventDatalayer';
import * as mriLayer from './genericEventMriLayer';

export const fireGenericEvents = (event: string, body: object) => {
handleLayerCall([dataLayer.genericGaEvent, mriLayer.genericGaEvent], event, body);
};
// similarly other GA events and MRI events are wrapped in one methods
// that helps us fire both the events from a same place

// genericGaEvent looks something like this on higher level
/* const genericGaEvent = (event, body?) => {
dataLayer.push({
event,
...body,
});
}; */

// similarly other events such as click events, Ecommerce events, load events
// are wrapped in high order functions like these

We’ve centralized our analytics event handling by creating a common file that unifies calls to both Google Analytics (GA) and our custom MRI Events. This approach simplifies event triggering, and makes maintenance easier.

import { IdleQueue } from "idlefy";

const queue = new IdleQueue({ ensureTasksRun: true });
//makes sure the tasks run on visibilitychange event

type LayerFn = (...args: any[]) => void;

const handleLayerCall = (layerFnList: LayerFn[], ...args:any[]) => {
if (!layerFnList || !layerFnList.length) return;
layerFnList.forEach((layerFn) => {
try {
if (typeof layerFn === 'function') {
//pushes the callback to requestIdleCallback queue
queue.pushTask(() => layerFn(...args));
}
} catch (error) {
console.error('Error handling layer function call:', error);
}
});
return;
}

export { handleLayerCall };

Optimizing Analytics with IdleQueue: To ensure optimal user experience and reliable data collection, we've used IdleQueue class. Here's how it enhances our analytics execution strategy:

  • Guaranteed Execution: IdleQueue ensures the execution of our analytics tasks, even when the page visibility changes (e.g., the user navigates away). This guarantees that our critical analytics data is captured reliably.
  • Prioritizing User Experience: By scheduling analytics tasks during browser idle periods, IdleQueue ensures that our user-facing interactions remain snappy and responsive. We avoid potential performance bottlenecks caused by synchronous analytics code.
  • Fine-Grained Control: IdleQueue provides flexibility by allowing us to force immediate task execution when necessary and configure minimum time budgets for task execution. This granular control optimizes our analytics workflow based on specific requirements.

The `Idlefy` package:

Philip Walton’s GoogleChromeLabs/idlize library is a fantastic tool for implementing the ‘idle-until-urgent’ pattern. However, modern web development demands more: TypeScript’s type safety, strict type checking, and a laser focus on optimized bundle sizes.

That’s where our Redbus library steps in! Building upon the strengths of idlize, we've crafted a TypeScript adaptation that shrinks the footprint while enhancing maintainability and reliability. After a successful 2 months long production run, we're thrilled to open-source this rewrite, empowering developers to effortlessly boost their INP scores and deliver a top-notch user experience.

Key Benefits:

  • TypeScript Precision: Enjoy the benefits of type safety, better code structure, and improved developer productivity.
  • SSR Compatibility: Our library seamlessly integrates with server-side rendering.
  • Light on Disk: An optimized bundle ensures minimal overhead and maximum impact on your website’s speed.

Our npm library includes additional helper methods and classes for even more granular performance tuning. To dive deep into its full capabilities, check out the detailed documentation at redbus-labs/idlefy.

The Results:

Before & After JS performance report

By deferring analytics tasks to idle moments with IdleQueue, we significantly reduced main thread congestion, directly improving responsiveness and INP scores.

This not only makes interactions feels responsive but improves INP score by reducing processing time and processing delay.(Refer to the image 1.1)

Analysing Field data

Before & After INP Graph

Our INP graph shows a positive shift. The percentage of 'Good' ratings increased from 58.41% to 70.79%. We also observed reductions in both 'Needs Improvement' (down to 22.40%) and 'Poor' ratings (down to 6.81%).

Tracking improvements over time

INP graph from week 1 to week 24, the arrow is when IdleQueue was implemented

Our INP graph tells the story. From the moment we implemented IdleQueue, we saw steady improvements over subsequent weeks.

Conclusion

The idle-until-urgent pattern, along with our optimized library, delivered tangible results: a 43.87% improvement in the CRUX P75 INP score (from 351 to 197)! This impressive gain came simply from deferring analytics with our IdleQueue wrapper, which leverages requestIdleCallback under the hood while adding essential guarantees for data collection.

If you’re looking for ways to enhance your website’s INP and create a more enjoyable experience for your users, idlefy library can be a great starting point. Give it a try and see the positive impact it can have!

Want more real-world engineering and web performance insights? Follow me and redbus articles for in-depth blogs like this one!

--

--

Harsh Choudhary
redbus India Blog

Self-taught programmer, a keen person with a curious mind and have a strong eye for design. An electronics student who also knows dependency injection.