Intermediate workshop
High-Speed Web Applications
JavaScript Performance Beyond the Basics
Interaction to Next Paint (INP) is a core metric in measuring web applications' responsiveness to user inputs such as clicks or key presses. In March 2024, INP officially became part of Core Web Vitals metrics as a replacement for First Input Delay (FID).
INP essentially measures the application responsiveness and interactivity at runtime. Having a good INP is crucial for a seamless user experience, which is one of the top demands of most users. Slow apps are as good as broken.
Metric represents the latency from an interaction to a point in time where browsers can potentially provide a visual update (perform a paint work). The core here is the word “potentially”. Browser does not require the actual paint event to happen; when measuring an INP, it searches for the next point in time where paint event execution is possible. That’s why sometimes you might see an INP value is measured and ready but the actual visual update is yet to happen. It is important to still keep an eye on the TBT metric when optimizing the INP. Even if the INP value is good, it can take a while before users will see the visual change.
Achieving an INP of 200 milliseconds or lower puts your interaction into a “Good” range. Values between 200 and 500 milliseconds are considered “Needs Improvement” values. And anything above 500 milliseconds is a “Poor” interaction.
Scheduling in web performance optimizations is similar to the role of a conductor in an orchestra. While the conductor may not improve each musician's individual performance, they ensure that every musician plays their part at the right moment, creating a harmonious symphony. Similarly, scheduling doesn’t necessarily speed up the code itself; instead, it negotiates with the browser to execute the code at the most opportune time.
In the case of INP optimizations, we schedule code execution to split the long tasks and potentially give the browser time to provide visual updates of the user interface. To use scheduling efficiently, we need to understand how the event loop works.
The event loop continuously checks and executes tasks in the browser’s queue, enabling it to manage asynchronous operations. This process runs constantly, allowing browsers to seamlessly handle asynchronous operations like events, rendering, and HTTP calls.
Macro Tasks & Micro Tasks: These are the types of tasks within the event loop. While macro tasks include operations like setTimeout and UI rendering, micro tasks involve Promise resolutions.
Non-blocking nature: As JavaScript is single-threaded, the event loop helps to maintain responsiveness by processing tasks one after another.
Rendering & Event Loop: Ideally, browsers should render at 60 fps. The event loop tries to ensure smooth rendering by effectively managing tasks.
By optimizing the timing and handling of tasks within the event loop, applications can achieve good INP.
Intermediate workshop
JavaScript Performance Beyond the Basics
To dive deep into the scheduling nuances, we need to create a controlled environment: a demo application.
In this application, we will change the color of the list items. Each change will be scheduled with a specific scheduling API.
First, we introduce the payload function:
This function emulates a scripting work by iterating over a for loop and, after that, executing the provided callback. Next we add the changeColor function that will paint our list items with provided color:
Before going into scheduling and INP improvements with RxAngular first, we'll experiment with common scheduling APIs:
setTimeout
requestAnimationFrame
requestIdleCallback
Promise.resolve
queueMicrotask
The setTimeout allows to delay function execution for at least a specified duration. The specified delay is the minimum time after which the function will execute. The actual time depends on when the event loop becomes free to process the task. The special pattern of setTimeout with a delay of 0 is a common way to defer the execution of a task until after the current call stack has cleared.
Explanation
First, there is some styling work before click (Hit Test, Pre-Paint, etc), which is our input delay, then click processing and styles recalculation, which is measured as processing time.
Then we have a new macrotask with pre-paint, layerization, and commit events. This is our presentation delay. The end of this task is the point where our INP value was measured because here, the visual update could potentially happen even though there was nothing to update.
After that, we see 3 subsequent macrotasks, which include our timers and square updates.
Style recalculation, paint, additional styling work and commit events happening in the next task. This is the point where we actually got our visual update.
Then we have 2 macrotasks where updates of the remaining 2 squares are executed.
And finally, the paint where the browser paints them on the screen.
Result
INP | 8 ms |
---|---|
Visual update | 130 ms |
Blocking time | 0 ms |
As we can see, setTimeout is not the best option for INP optimizations.
Unreliability: It operates independently of the browser state, which means tasks scheduled with setTimeout could be executed at inopportune moments.
Unpredictability: As we saw in our example, the first paint event only occurred after 3 squares were updated. Since we scheduled using a 0 ms delay, the browser couldn’t find the room before that.
setTimeout should not be used for UI-sensitive work, but it is still an OK option to delay the execution of the less critical tasks. Also, that’s a good option for frequent events rate-limiting (debouncing and throttling).
requestAnimationFrame (rAF) is a browser API specifically designed for creating smooth and synchronized animations. It delays a callback to run just before the next repaint, rAF aligns the execution with the browser’s refresh cycle, ensuring animations appear fluid to the user without unnecessary layout thrashing or jank.
Explanation
As with setTimeout, first, there is some styling work before click (Hit Test, Pre-Paint, etc), then click processing and styles recalculation.
Then, since nothing else is happening in the main thread, we have a new macrotask with all our animation frames and square updates. Then under the same task happens styling work, paint, and commit.
Since all rAFs run under the same macrotask, as a result, we introduced a long task of 202 ms and got an overall INP value of 206 ms.
Result
INP | 202 ms |
---|---|
Visual update | 202 ms |
Blocking time | 202 ms |
While requestAnimationFrame shines at optimizing animations, it is unsuitable for INP optimizations. If multiple rAF calls are made, they will all execute in the same macrotask before the next paint. As a result, we might get bad INP value and introduce long tasks.
requestIdleCallback offers a way to schedule work during the browser's idle periods, ensuring high-priority tasks don't compete with less important ones. By queuing callbacks to run when the browser is idle, we can optimize resource usage and improve performance. An interesting aspect of this API is the timeout parameter, which ensures that time-sensitive tasks are executed within a certain timeframe. Unlike the previous 2 scheduling APIs, requestIdleCallback is not patched by zone.js.
Explanation
Very familiar pattern at this point - styling work before click then click processing and styles recalculation.
Then the browser applies changes to the first square.
Browser executes the styling and painting work for this square because it has higher priority than the update of the next square.
And this repeats for the remaining 4 squares.
Result
INP | 8 ms |
---|---|
Visual update | 51 ms |
Blocking time | 0 |
While requestIdleCallback at first glance might seem like an elegant solution for solving the INP issues, it is actually the worst thing to use if you need to schedule rendering-related work.
Unpredictable Timing: Execution depends on the browser's idle periods, which can be sporadic, making requestIdleCallback unreliable.
Delay by Higher Priority Tasks: As we saw in our measurement, the execution of idle callback for the second square was delayed by painting the first square. In this case it played in our favor, however it highlights pretty well the unpredictable nature of this API.
Rendering-related work is not a good candidate for scheduling using requestIdleCallback. However, it is still a great option for deferring resource-heavy computations (i.e., storage reads/writes and analytics tracking) to idle periods, which actually really helps with INP.
Promise.resolve and queueMicrotask are two mechanisms for scheduling microtasks. They are small jobs that run after the current execution context but before the browser has a chance to continue with other tasks. Basically, it’s the opposite principle of requestIdleCallback. Here we’re ensuring that our work has the highest priority.
queueMicrotask is specifically designed for this purpose and is the recommended modern approach over Promise.resolve, which was often used as a workaround before queueMicrotask was available.
Explanation
For both APIs, the picture will be the same.
This time styling work happens before click then click handler, which was bloated with our square updates, and then we have styles recalculation. All of this results in a 200 ms long task.
After that, the browser performs a paint, some styling work and commit.
Result
INP | 200 ms |
---|---|
Visual update | 200 ms |
Blocking time | 200 ms |
Initial instinct might tell you that scheduling visual update-related work with Promise.resolve or queueMicrotask is a good idea because of their priority and predictability. However, this would be a huge mistake because by doing so, you force browsers to execute all work under the same macrotask, thus having a high chance of introducing a long task.
With requestAnimationFrame scheduling you at least have a glimpse of hope that some paint might happen from rAFs scheduled before or some commit work will happen. With Promise.resolve() or queueMicrotask, you willingly delay the rendering process by at least the length of the work you schedule with them.
Those APIs are excellent for some cleanup actions or order-sensitive operations; however, they have very limited applications to solve performance problems.
Advanced workshop
High-Speed Angular applications on any device
RxAngular is a set of packages specifically crafted for performance and DX improvements. Currently, it provides 5 packages:
@rx-angular/state - lightweight state management solution for local and global state management in Angular applications.
@rx-angular/cdk - core technologies for performance tuning in Angular. It includes scheduler, rendering strategies, utilities for zone-less applications, and many more.
@rx-anguar/template - set of directives and pipes for concurrent rendering in Angular.
@rx-angular/isr - improvements for Angular Universal applications
@rx-angular/eslint - set of linting rules to enforce best performance practices.
At the core of RxAngular scheduling lies its scheduler, which is a customized version of React's concurrent mode scheduler adapted for Angular applications' specifics and needs. It contains a queue of concurrent tasks and composes a set of macrotasks sorted and executed in a defined order.
Before starting, we need to inject the RxStrategyProvider service into our component:
We add our button and call the schedule() method of the RxStrategyProvider:
Explanation
And yet again there is a known pattern first - styling work before click (Hit Test, Pre-Paint etc) then click processing and styles recalculation.
Then the browser applies changes to the first square.
The browser executes the styling and painting work for this square because the scheduling mechanism ensures there is enough room for it.
And this repeats for the remaining 4 squares.
Result
INP | 8 ms |
---|---|
Visual update | 51 ms |
Blocking time | 0 |
Looks familiar, right? It is much like the requestIdleCallback scheduling, but this time, we’re not down-prioritizing the scheduled work and have the advantage of predictability.
The paint of the first square was not a deus-ex-machina or a coincidence, it was actually a controllable process. Scheduler got our back and left some room for painting.
The biggest advantage of this type of scheduling is that it fits very well for any kind of work. But we will get into more advanced features later.
Contrary to requestIdleCallback or setTimeout, our scheduled work does not go immediately to the macrotask and gets executed when the time is right. Scheduler maintains an internal work queue and chunks it based on the available frame budget.
Frame Budget: The scheduling process has a notion of frame budget, and by default, a frame budget is set to 16ms.
Chunking: It aligns closely with the frame budget concept. By splitting work into smaller chunks over time, chunking helps achieve the frame budget and smoother frame rates by preventing the main thread from overloading with lengthy tasks.
In the previous examples, each square change took approximately 40 ms. But what will happen if the execution time of those tasks is less? Let’s find out by adjusting the payload function.
Explanation
Styling work before click (Hit Test, Pre-Paint, etc), then click processing and styles recalculation.
Scheduling kicks in. The algorithm starts to fill a future macrotask. First, the square update gets executed. The execution time of each square update is 5 ms.
Scheduler precisely measures the execution time and excludes it from the frame budget (16ms). Since 3 squares took 15 ms and there was still a remaining budget of 1 ms, the update of the fourth square was added to the same macrotask, after which the budget was exceeded.
Styling and paint work gets executed, which provides a visual update to the user.
After that update happens to the last square.
When scheduling using RxAngular scheduler, you do not explicitly schedule work to be executed under one or multiple macrotasks. You delegate the decision on how many macrotasks to create to the scheduler itself. And thanks to its algorithm, it will compose work with the aim of the best performance possible.
Another great advantage of RxAngular scheduling is full control over the execution timings. We not only guarantee that the time is predictable but can also arrange the execution of code as we like.
To achieve this level of control we will use another feature or RxAngular - the Concurrent Strategies. Each of the 5 concurrent strategies offers a unique approach to scheduling and executing tasks, allowing us to optimize performance based on different tasks' specific needs and priorities. And here, we need to introduce 2 new concepts:
Deadline: The render deadline is a predefined time window within which planned tasks must be completed. If the scheduled tasks are not completed within this time frame, the chunking process is halted. Instead, all remaining work is executed as quickly as possible in a single synchronous block.
Priority: It dictates the order and timing of tasks. Prioritization ensures that critical tasks are handled promptly for optimal user experience, while less urgent ones are deferred.
@rx-angular/cdk concurrent strategies are:
Immediate has the highest priority and a deadline of 0 milliseconds, which means if you schedule something using this strategy, it will be executed immediately under a single macro task.
User Blocking has a deadline of 250 milliseconds and is suitable for lightweight tasks that should have high priority.
Normal is tailored for heavy work that requires longer execution time. The deadline of 5000 milliseconds creates a balance between responsiveness and the need to handle the scheduled work.
Low is perfect for secondary work. The deadline of 10000 milliseconds makes it suitable for a low-priority background process that is not directly related to user interactions.
The idle strategy is designed for tasks that are not urgent and can be deferred until the system is relatively idle. It has the lowest priority among the strategies and doesn't have a specific render deadline.
For the demo, let's change the color of the squares in the next order:
Square 5
Square 3
Square 1
Square 2
Square 4
We create a new function that will map the index of the square to the appropriate strategy name and call the schedule method.
Explanation
The process is exactly the same as with default scheduling with normal payload. However, we can see that the order of operations is exactly as we wanted it to be.
With this example, we can see that another great advantage of RxAngular scheduling is the predictability and full control over operations. This makes it a great fit for scheduling not only rendering-related work but any work in general. In more advanced use cases, it is easily achievable to prioritize rendering over, for example, state updates and state updates over analytics tracking.
Using the RxStrategyProvider.schedule() method to optimize INP and overall performance might be too low-level. That’s why @rx-angular/template offers a set of template-level directives that give you better control over UI updates and application performance.
*rxLet - offers an advanced approach for handling asynchronous values within Angular templates, addressing limitations of the async pipe by reducing over-rendering and boilerplate. It allows users to control rendering on the TemplateRef level instead of the full component.
*rxIf - enhances Angular @if (*ngIf) by efficiently handling observables for conditional template rendering, offering a more performant and flexible approach to dynamically manage templates based on observable states.
*rxFor - a more performant alternative to @for (*ngFor) loop. Boosts the performance of lists rendering in your applications.
*rxVirtualFor - directive enhances Angular's list rendering with virtual scrolling, efficiently displaying only visible items and improving large dataset performance. It utilizes absolute positioning and transformations for smart DOM management, offering an advanced, performance-optimized virtual scrolling solution.
While native scheduling APIs can be somewhat useful they are not tailored to improve INP. Each of the overviewed APIs has its own set of limitations, and achieving good results could be a cumbersome process. RxAngular has the best parts of native APIs and its own unique features:
We can be sure that rendering work will be executed in between the scheduled work
There is a very high level of control over the execution order
Scheduler makes sure that work is composed in a way that will maintain high performance of your application
To finish things up, let's take a look at the summary of all our measurements and the key comments.
setTimeout | requestAnimationFrame | requestIdleCallback | Promise.resolve() & queueMicrotask | RxAngular Scheduling | |
---|---|---|---|---|---|
INP | 8 ms | 206 ms | 8 ms | 206 ms | 8 ms |
First visual update | 130 ms | 206 ms | 51 ms | 206 ms | 51 ms |
Blocking time | 0 ms | 202 ms | 0 ms | 200 ms | 0 ms |
Comment | Its unpredictability makes it unsuitable for tasks critical to UI, but it's still effective for delaying less urgent tasks and managing event frequency. | It can mess up INP optimization by bundling tasks into a single macrotask, with the risk of longer tasks and worse INP scores. | Useful for offloading heavy non-rendering tasks during idle times, unreliable for rendering work. | Using Promise.resolve or queueMicrotask for UI tasks can cause longer execution times within the same macrotask, negatively affecting INP. | Optimizes performance by smartly chunking tasks based on frame budget, offering predictable execution and full control over task prioritization with its concurrent strategies |
Special thanks 💖
To Hanna Skryl for reading this article at least 10 times and giving amazing content advice.
To Julian Jandl for debugging the performance measurements with me at 1 AM and providing very valuable feedback.
To Iulia Enescu for taking care of all the publishing chores.