When was the last time you opened a page on a website and tried to click on something multiple times for it to work? Or the last time you swiped on an image in a carousel and it stuttered and shifted around unnaturally?
While this type of experience happens far too often, we can use tools that can help us make better, more responsive experiences for users. Scheduling and prioritizing tasks efficiently can be the difference between a responsive experience and one that feels sluggish.
At Airbnb, we’ve been collaborating with the Chrome team on improving performance using a prioritized task scheduler to implement new patterns and improve the performance of existing ones.
Meet the prioritized postTask scheduler
The prioritized postTask API is designed to give us more flexibility and power around scheduling tasks efficiently. Similar to requestIdleCallback and setTimeout, using it effectively can help reduce Total Blocking Time, First Contentful Paint, Input Delays and other key metrics.
While many performance efforts focus on the initial page load, we wanted to improve the user experience after the page has loaded. We used the postTask scheduler in a number of way — from how we preload images in carousels to making our map more responsive.
To get a sense of the progress we’ve made since this effort began, we created new real user monitoring performance metrics and leveraged existing lab-based metrics from tools such as WebPageTest and Lighthouse.
How it started
How it’s going
What is the postTask Scheduler?
Much like requestAnimationFrame, setTimeout or requestIdleCallback, scheduler.postTask allows us to schedule a function on the browser’s event loop. That function then gets prioritized and run by the browser.
“The first two — microtask and don’t yield — are generally antithetical to scheduling and the goal of improving responsiveness. They are implicit priorities that developers can and do use.”
Breaking up long tasks
We can and should break our long tasks up in order to improve responsiveness. Here’s an example of a long task that sets up basic error tracking and event logging. Notice how the browser has flagged the task as a Long task.
Once we identify a long task, we can use postTask to break up the tasks into smaller ones.
Currently postTask is implemented in Chromium behind the #enable-experimental-web-platform-features flag under chrome://flags, and is planned to be fully supported in Chrome in an upcoming release. While a polyfill exists, at the time of this writing the polyfill hasn’t been open sourced yet. The official polyfill status can be tracked on the WICG repo, but until then we can try it out on Chrome Canary.
In the above example, we pass a new delay and priority argument to postTask, telling it we want to run our task in the background after waiting for 1 second. The postTask scheduler currently supports 3 different priorities.
One of the benefits of the postTask scheduler is that it’s built on top of Abort Signals, allowing us to cancel a task that is queued but hasn’t yet executed. The API also defines a new TaskController, which allows for the priority to be used by the signal to control tasks and priorities.
Deferring non-critical tasks
Most sites load a large amount of third-party libraries, such as Google Analytics, Tag Manager, logging libraries and so much more. It’s important to measure and understand the impact these have on a user’s initial loading experience.
Introducing the experimental “scheduler.wait”
scheduler.wait is a proposed extension that allows for waiting for some milestone in the page, a custom DOM event in our case. Let’s see how to use it to load Google Tag Manager after we finish loading our page.
The beauty of this is its simplicity — we get a promise back that we can block on until our custom event is triggered.
We can also specify this on any task as an option passed to postTask, allowing us to further simplify our Google Tag Manager registration.
It’s relatively painless to polyfill the wait function as it is defined today, since it takes the same options that postTask does but doesn’t require a task to be specified.
We also needed to create a wrapper around the postTask method used in the shim above to support the event option when calling scheduler.postTask.
The code for the waitForEvent call above is a wrapper mapping a DOM event to a promise that resolves when it fires, allowing us to wait for any event within our postTask wrapper. There’s no standards-defined way on how this might go yet. Our implementation is very naïve — for example it can’t reset the state of an event after it’s been fired if we were in a single page application and the page changed. In such a case we would likely want to fire a new loading event. There are a range of options being explored in the proposed API addition, so expect this to continue to evolve.
Use case: Prefetching important resources
Preloading the next image in an image carousel or the details of a page before the user loads it can dramatically increase the performance of a site and its perceived performance to users. We recently used the postTask scheduler to implement a delayed, staggered, and cancelable image preloader for our main search results image carousels. Let’s see how to build a simple version of it using postTask.
Requirements for our image preloader on our listing carousels:
- Wait until the listing is about 50% visible on the screen
- Delay for one second; if the user is still viewing it, load the next image in the carousel
- If the user swipes on an image, preload the next three images, each staggered 100ms after the previous one begins
- If the carousel leaves the viewport at any point prior to the one second timer finishing, we should cancel all of the preloading tasks that have not completed yet. If the user navigates to another page, also cancel all of the preloading tasks
Let’s start by looking at the first part of this, which is preloading the next image in the carousel if the user scrolls the card at least 50% into view for one second. While we use React for the next few examples, it is not required. All of the concepts here can also be achieved with other frameworks, or importantly, no frameworks at all.
Let’s assume we have a method called preloadImages that starts fetching the next images, and toggles a boolean field when it has completed preloading the images.
We can combine that with an intersection observer and the postTask scheduler and accomplish loading the second image after being 50% in view for one second.
There’s a whole bunch of logic going on behind the scenes, so let’s break it down into smaller steps to understand what’s happening.
Let’s start by looking at how to determine if a user has come into view at least 50%. For this task we can use intersection observers. We use a small helper to set them up, but you can also use them with no library.
Now that we know when we’ve scrolled into view 50%, we can wait for them to stay in view for one second with a useEffect hook combined with scheduler.postTask.
Since we also passed an associated TaskSignal, the call to abort() will cancel any pending calls to preloadImages when a user scrolls out of view. We also take care to remove the reference to the controller to allow restarting the flow if they scroll back into view.
Staggering network resources
The last requirement we need to implement is staggering the next few image requests, each by 100ms after a user swipes on a carousel. Let’s see how we can modify our existing code to account for this scenario using the postTask scheduler. First let’s add a hook to call our preload logic with three images to preload when a user interacts with it. We’ll skip the first image since we’ve already loaded it.
One of the goals of the postTask scheduler is to provide a low-level API to build on top of. We’ve been building up an integration that lets us perform a number of different patterns or strategies when used within React that we think are very useful.
Using postTask effectively in React
While having a custom integration with React, Vue, Angular, Lit, etc. isn’t necessary, we can gain some major benefits by doing so. In React, for example, when a component unmounts, we typically want to cancel any tasks that are still queued up.
We can do that by passing a function as the return value to useEffect. Remembering to do that every time however is a challenge, and not doing it can lead to memory leaks. It’s also a challenge to remember to catch any AbortError the scheduler throws when we call abort(), as those are very much expected, but not something we can make a blanket exception for.
Let’s lay out some nice-to-haves for a usePostTaskScheduler hook that will make it easier for folks to consume.
- Pass an enabled flag, allowing the ability to bypass the scheduler to make A/B testing easier
- Allow for easy cancellation, including auto canceling on unmount
- Propagate the signal automatically to scheduler.postTask and scheduler.wait
- Catch and suppress AbortErrors or anything that looks like them
- Support robust debugging capabilities
- Allow for specifying a strategy for common patterns such as the 2 we covered in this post
- Add a hook for waiting for a delay to complete
While going into the implementation of this hook is beyond the scope of this article, let’s see how this simplifies using the postTask scheduler within React. Let’s delay loading a high-cost, low-importance React component until after the load event fires, as well as cleanup some old localStorage states after load as well.
Here we can see that we have a boolean flag that will indicate when loading completes, as well as a traditional callback that lets us cleanup our localStorage keys. In this instance, if this component unloads prior to that event, we will cancel the task to cleanupLocalStorageKeys and never render the <ExpensiveComponent />. In our case, the ExpensiveComponent loads asynchronously, so by deferring it, we reduce the cost of initial hydration significantly in both blocking time and bundle size costs.
Let’s see how we can defer loading our service worker until five seconds after the load event in the background.
The path ahead
Chromium is the first to implement and prototype this new API; however, the API is being developed openly in the WICG with the goal of being standardized and adopted by all browsers. It’s also worth noting that even without native support, we’ve seen a number of performance improvements in browsers like Safari and Chrome by using the polyfill as it enforces good prioritization and scheduling patterns.
We’ve been delighted to get the chance to test the postTask scheduler, and hopefully in this blog post you have a sense of the developer experience and performance improvements it enables. We’re looking forward to seeing it progress through the standards body and begin to land across web browser vendors.
This effort was made possible with the support of so many folks. We are grateful to Scott Haseley (Google), Shubhie Panicker (Google), Aditya Punjani, Josh Nelson, Elliott Sprehn, Casey Klimkowsky, Etienne Tripier, Victor Lin, and Kevin Weber.
Improving the guest experience
Over the last year, in addition to performance, we’ve worked on reengineering large parts of our tech stack to support new product use cases for our guests. Go behind the scenes with our Guest Experience tech team by registering for our upcoming Tech Talk, REENGINEERING TRAVEL on June 8th at 12pm PST. In this talk we’ll share more about how we’re building our platform and processes to fit the travel of today.