DEV Community

Cagdas Ucar
Cagdas Ucar

Posted on

Preact Async Rendering: Solution to Initial Render Blocking

The traditional way of building websites is now called multi-page application (MPA). In this classic mode browser makes a call to the web server to get a page. Once the page is loaded, the dependencies of the page (styles, JS, images) are then requested from the same server or supporting servers. The problem is that many of the pages share the same data and it's inefficient to re-request the same data over and over. Furthermore, MPAs cannot support transitions between pages. There is a sharp cut-off and visible loading time in most cases when switching pages.

Single page applications came to existence around 2010 exactly for this reason. First frameworks were Ember, AngularJS and Backbone. All technologies take time to mature and SPA is no exception. Since the beginning traditionalists had a number of arguments against using SPA frameworks.

The first argument was that it was bad for SEO and search engines would not be able to index the site properly. I actually remember discussing with a developer circa 2013 about this. I was arguing against it at the time. Those days are long gone. Google now actually encourages SPA websites.

The other argument traditionalists had against SPA is complexity but that's being taken care of by many frameworks, making it easier and easier. There are thousands of hours of training materials for many frameworks.

That being said, the biggest challenge modernists faced was probably the initial loading delay. SPA client side rendering takes time to initialize. During that time, the screen is either empty or just says loading or some image icon. In order to solve that problem a new technology emerged: server side rendering (SSR). In this mode, the same application is rendered only for the requested page on the server and that's sent in place of the loading screen. The client side then takes over and updates the page if needed but usually just updates the events for the SPA to work, which is called hydration.

Blocking Render

It's been 12 years at this point since the initial SPA frameworks and you would think we have completed every challenge but there is one more and that's probably the biggest one: initial render blocking. You may use SSR to send the rendered page but initial client side rendering (CSR) can still take a significant amount of time. During that time, the browser will be busy and non-responsive to the user commands. It's usually pretty short (less than 300ms) but it's definitely there.

Here's what it looks like on performance tab of dev tools (see the big block of 100ms render task):

Blocking Render

Google created a new set of performance metrics called web vitals. They consist of 3 metrics: Largest Contentful Paint (LCP), FID (First Input Delay) and CLS (Cumulative Layout Shift). I'm not sure if web vitals already started contributing towards SEO but we all know that the day is coming soon if it's not already here. Here's the thing: First Input Delay is a big challenge for single-page applications due to the initial render blocking. You may also see a version of this metric as "total blocking time" in Lighthouse. Multi-page applications usually don't have that problem and even today many people choose the traditional way of building websites for this reason.

Web Workers

There are some documented solutions for this problem using web workers. Web workers run on secondary CPUs, so they are not blocking.

The problem is that working with web workers is a pain. They can't change the DOM, so how can we use them for rendering? The thing is, rendering actually consists of 2 activities: "diff" and "commit". The best way would be to move the "diff" to the web worker and have it relay the commits needed to the main thread. The problem with this approach (apart from its complexity) is that the application itself ends up living in the web worker because diff also includes the application code for rendering and other events. Because the web worker is running on the secondary CPUs and in mobile devices these are slower chips, having the entire application in web worker is a non-starter in many cases. Splitting the application code to the main thread while keeping the diff in the web worker would be ideal but that would require too many communications between the main thread, which would end up making it slower.

How does Async Rendering work?

The ideal solution is to break the initial rendering into little pieces. Browsers have an API for that called requestIdleCallback. The program asks: "hey browser, I need to do some work. how much time can you give me?" and the browser answers: "here you go, run for 20ms and then check with me again to get more time" and so it goes until the render is completed. This way the render is not "blocking" but "cooperative". This is also known as "interruptable rendering" or "asynchronous rendering".

Ideally, this should be implemented at the framework level and there are a lot of discussions but none of the SPA frameworks have a complete solution for it yet. I think it's a problem for millions of people.

React Async Rendering

React did a re-write in 2016 exactly for this problem but in the end, they ended up disabling the feature because they had too many bugs. I think the main problem is that they were trying to do "concurrent rendering" where the components can be painted in different order. They are now saying they will enable those features with React 18 but I don't think it's the solution people have been waiting for. They ended up introducing breakpoints in the application via Suspense. So, the developers are supposed to determine where to place breakpoints in the code to break the initial rendering. This shifts the responsibility to the web page designer who probably has no clue about what render blocking is. Nobody wants to deal with that. Aziz Khambati seems to have a good solution for React renderer but I don't think that's going to be the official release.

Fine, but I Need Something Now!

This brings us to our project. WebDigital is a platform that enables users to develop websites visually. That's nothing new but I think we are the only one that generates contents as single page application (SPA). The problem is that our websites were suffering from large first input delays around 300ms on mobile devices. The framework that we use is called Preact, which is compatible with React but it's a faster implementation. I'm sure somebody will implement async rendering at some point but we needed sooner than that.

Deep In Code

I started looking at the source code of Preact. Render gets triggered from 2 places: initial rendering and components. Render then "diffs" and "commits" recursively. I believe this is quite common structure among many SPA frameworks. The key to breaking up the rendering is to occasionally check with the browser using requestIdleCallback and get a certain amount of time to execute. When we exceed that time, we need to wait until another call to requestIdleCallback returns us more time. JS developers will recognize this requires async/await.

My first implementation was naïve: make all recursive routines async and await requestIdleCallback. It worked but apparently async/await performance is quite bad when you recursively call them hundreds of times. My render time went from 100ms to 400ms, not counting the breaks.

In order to solve the performance problem, I decided to use generators. In this architecture, only the outermost caller (render) is an async function and it calls a generator function until it returns a Promise, which happens only when we exceed the time limit. Then, when a Promise it returned, we await until requestIdleCallback returns us more time. This still reduces the performance but not as drastically. 100ms render took around 130ms, not counting breaks. Should be acceptable.

Alas, there were more hurdles to overcome. Just having async functions in the code increased Preact bundle size by 2K! For a framework claiming to be the smallest, this is not acceptable. So, I started working on a separate bundle. I had to take the "blocking" functions and turn them dynamically into "generator"/"async" functions. Due to this operation, minifier (Terser) renaming/mangling properties broke the code. So, I added certain variables that are used in async function generation as "reserved". I then created a separate bundle that contains the preact regular code as well as the async version.

With this new approach, Preact core bundle size only increased by 46 bytes (minor changes and adding couple of hooks to override component rendering). The async bundle takes 6K but it should be possible to reduce it in the future. Note that we are NOT doing "concurrent rendering" where the components can be painted in different order. We are awaiting for each component render to be completed when processing the render queue. I believe this is the way to avoid bugs encountered by React team.

Results

Here are the async rendering stats (note that the big block of 100ms render task is now executed over many little tasks):

Async Render

Bear in mind that this is still under review by the Preact team but if you need it desperately like us, feel free try out the preact-async package on npm. I'm hoping that Preact team will accept this change and get it into the main package.

Here's the main usage:

  • Install preact-async instead of preact.
npm remove preact
npm i preact-async
Enter fullscreen mode Exit fullscreen mode
  • Alias preact as 'preact-async'. This process may differ for different bundlers but here's how to do it for webpack:
resolve: {
    alias: {
        react: 'preact/compat',
        'react-dom': 'preact/compat',
        preact: 'preact-async'
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Due to the async nature of the module, certain variables need to remain unchanged. This list is exported from this module and can be used for minification purposes. Below is example usage in webpack. If you minify the code without these reserved tokens you will get an error.
optimization: {
  ...
  minimize: true,
  minimizer: [ 
    new TerserPlugin({ 
      terserOptions: { 
        mangle: { 
          reserved: require('preact-async/async/reserved').minify.mangle.reserved 
        } 
      } 
    }) 
  ]
}
Enter fullscreen mode Exit fullscreen mode
  • Here's the code to use it:
import { render, renderAsync, h } from 'preact/async';

// create main application component
const mainComponent = h(App, {});

// serial rendering - use replaceNode if using SSR
render(mainComponent, document.getElementById('root')); 

// async rendering - you can await it - use replaceNode if using SSR
renderAsync(mainComponent, document.getElementById('root-async')); 
Enter fullscreen mode Exit fullscreen mode

If the environment does not support async functions/generators or running on the server, async rendering will fall back to blocking rendering.

Final Notes

It's usually the initial render that's the problem but in some cases component renders may need performance optimization as well.
renderAsync will continue to respect browser time when processing render queue but if you are using blocking rendering you can always use options.debounceRendering = requestAnimationFrame for Preact.

This methodology should be applicable to any framework out there.
The basic idea is to create async/generator functions from serial functions dynamically and insert a breakpoint at the start of recursion for render. Hopefully someone will find it useful.

Top comments (0)