Hacking React Native Unbundles into UWP

  • June 27, 2017
  • Views

    986

We recently wrapped up development on “unbundle” support for react-native-windows. An unbundle is an optimized packaging mechanism for React Native that supports on-demand, lazy loading of JavaScript modules. Our goal was to reduce launch times and memory usage for React Native Windows apps.

The Problem

We’ve been working with BlueJeans on porting react-native-windows to WPF to complement react-native-windows’ existing support for UWP. A gnificant portion of BlueJeans’ desktop application usage comes from Windows 7 devices, so using react-native-windows only for Universal apps was not an option. One of their key metrics is app package size and app start time. When a BlueJeans user joins a meeting for the first time, they want the download, install, and meeting join experience to be as fast as possible. One of the problems with React Native is that before anything else can occur in the app, the JavaScript bundle has to be loaded into the JavaScript runtime on the device. For large bundles, this can mean hundreds of milliseconds of delay in addition to any operating system overhead. We saw unbundles as an opportunity to reduce some of this startup penalty.

What are “unbundles”?

If you’ve ever developed a React Native app for iOS, Android or Windows, you are probably aware that the collection of JavaScript modules and components you write are bundled up into a single blob called the JavaScript bundle. Bundles are produced either from the packager server or from the React Native CLI.

The packager server is the background process launched using npm start from your React Native app directory. It is also started automatically when you run react-native run-[ios|android|windows]. The React Native app has a developer mode component that connects to this packager server to download the JavaScript bundle for your app. It also monitors for potential changes to your app for live and hot reloading.

Screenshot of React Native packager server

Bundles produced from the command line have additional options. For example, using the --dev flag, you can specify whether you want to generate a debug or release bundle. The primary difference is that release bundles have the global __DEV__variable switched off, skipping over debug-only statements and assertions. Release bundles are also minified.

There is also the unbundle command, which produces another variant of JavaScript bundle. There are actually two kinds of unbundles, file-based unbundle and indexed unbundle.

A file-based unbundle has a bundle file for startup code located in the same place as a regular bundle. It also has a js-modules folder in the same directory as the startup code bundle. Inside the js-modules folder is a file called UNBUNDLE with a binary header that matches an expected value in React Native. There is also a file per module in the js-modules folder that gets loaded on-demand using the nativeRequire native callback function.

The other kind of unbundle is the indexed unbundle. Loading many small files generates many separate I/O requests, as each file is loaded on demand. For some platforms, iOS in particular, the overhead of I/O can outweigh the potential benefits of the unbundle. The indexed unbundle addresses part of the I/O problem by bundling all the JavaScript modules into a single file. The beginning of the file is a binary header that includes a mapping table from module index to the offset and length of the module in the unbundle file.

To generate an unbundle, use the following command:

You can also use any of the other CLI options from the bundle command above. To force the indexed unbundle, use the --indexed-unbundle flag.

Implementing for Windows – The C# Approach

The IJavaScriptExecutor interface is an abstraction used by the React Native bridge to load JavaScript bundles, call functions, invoke callbacks, and retrieve the batched queue of native methods that should be invoked.

The first implementation of IJavaScriptExecutor we created used C# and P/Invoke to call native methods from Chakra.dll. We use a JSRT wrapper to have a simpler, managed API to the Chakra JavaScript runtime. For more information about using Chakra from C# and .NET, check out this code story on the topic.

In the C++ bridge implementation for React Native on iOS and Android, the JSCExecutor, which is analogous to IJavaScriptExecutor, introduced an API to set an unbundle instance. A conditional check to determine if the app JavaScript bundle is an unbundle was also added at an earlier stage in the React Native bridge, outside of the JavaScript executor component.

To check if the app JavaScript bundle is an unbundle, the framework needs to do a bit of file I/O. For a file-based unbundle, the framework must search the bundle directory for the relative path ./js-modules/UNBUNDLE and confirm that the file includes the correct binary header. Likewise, for an indexed bundle, the framework must also check the bundle file for the correct binary header.

Rather than spread out the logic to perform file I/O operations across different layers of the React Native bridge, we chose to encapsulate the entire unbundle implementation inside IJavaScriptExecutor. In the RunScript function, we added the following logic:

When an unbundle is recognized, we create an instance of the IJavaScriptUnbundle abstraction that can extract the startup code using GetStartupCode and can get the source code of a module from a given index using GetModule.

We also added a global native function to the Chakra runtime instance to implement the nativeRequire behavior expected by the React Native JavaScript.

 

When the packager server produces a bundle or an unbundle, it uses a Babel plugin to rewrite require calls from a module name to a module index. React Native polyfills the require method to look for modules that have already been loaded given the module index, or, if not available, calls the nativeRequire method to request that the native framework load the module. This method then calls one of our unbundle interface implementations to either load the module from a file, or from the indexed unbundle.

Initial testing of the C# approach did not meet our expectations. Using the file-based unbundle seemed significantly slower than even the regular bundle approach, and the indexed unbundle did not seem to improve the performance over that of the regular bundle. Our intuition was that the managed file I/O overhead exceeded the performance benefits of the unbundle, so we started looking into a C++/CX approach instead.

Implementing for Windows – The C++/CX Approach

We had previously implemented a C++/CX version of the IJavaScriptExecutor to investigate its performance benefits. For regular bundles, we didn’t see much of a reason to switch, as the performance characteristics were not drastically different, and the C++/CX implementation was less portable than the C# variant.

However, we felt we could create a much more performant unbundle implementation in C++/CX than in the C# IJavaScriptExecutor, specifically because we could use memory-mapped files. Memory-mapped files have a strict paging behavior, where parts of a file are read into memory in page increments, and file contents are accessed by byte offset in virtual memory rather than by seek behavior. Depending on the module size, the indexed unbundle can fit multiple module records on a page of memory. For example, the average module size for the UWP React Native Playground app is 1.5KB, but more than 60% of the modules are less than 1KB, more than 40% of the modules are less than 512 bytes, and 25% are less than 256 bytes. That means there are likely quite a few pages that fit four or more modules per 4KB page.

We created a similar abstraction to the IJavaScriptUnbundle interface above as a base class in C++. We decided to support both file-based and indexed unbundles for C++/CX, even though we were reasonably certain the file-based unbundles would not be performant on Windows.

We created a helper method to load the memory-mapped file.

From initial testing, we had a significant improvement in the indexed unbundle performance over the C# approach.

Unbundle Load Time Results

We needed a way to measure relative load times of each React Native bundle type. In react-native-windows, native modules are instantiated before any JavaScript is evaluated. We created a new native module that started a timer when the native module static constructor was called, and provided a ReactMethod to read the current elapsed time from it.

We created a dummy app that would call the StopwatchModule when the main app component mounted.

 

While this stopwatch approach would not give us a precise measurement of how long it took to load the initial JavaScript, we can compare the relative performance of different JavaScript bundle approaches, assuming all other startup overhead is relatively constant. The difference in the captured stopwatch time would vary only due to differences in the time to load the JavaScript, as all other aspects of the framework were kept constant. We tested the app on a Surface Book running Windows 10 with an Intel Core i7 and 16 GB of RAM.

We found that the C# approach for file-based unbundles were by far the worst performing option, even worse than regular bundles. The C++/CX approach to indexed unbundles produced the fastest load times, about 70 milliseconds faster than the C# approach for regular bundles. The C++/CX file-based unbundle approach also produced a speedup over the C# regular bundle.

Bundle LoaderAverage Load Time (ms)
C# Regular Bundle (MB)297
C# File-Based Unbundle (MU)1695
C# Indexed Unbundle (MI)290
C++/CX Regular Bundle (NB)249
C++/CX File-Based Unbundle (NU)257
C++/CX Indexed Unbundle (NI)229

In general, we also found that the file-based unbundles were a bad idea because the minimum file size in Windows is 1KB. For BlueJeans, app package size is equally as important as load times, because they want their first-time use of the app to launch as quickly as possible. Having a larger app package means more time downloading that package. Also, more data would be used to install the app, which is an important metric for their mobile users. Likewise, considering the many files in the file-based unbundle approach, the time to decompress the app package would be longer than a single file.

Instead of using the ad hoc stopwatch module measurement approach, we could have captured ETW traces that measured the load times directly. This would have involved a bit more work to script a deployment mechanism and a tool to read the results from the .etl files, and ultimately we only cared about the comparative performance. There is more information about capturing ETW traces in react-native-windows on GitHub.

Serialized Bytecode Bundles

We also wanted to compare against an existing experimental feature of react-native-windows that used the built-in capacity of Chakra Core to pre-parse and serialize JavaScript, and run that serialized code directly.

The serialized bytecode is produced by using the JsSerializeScript API. We write the output of that API call to a specific path in local storage the first time the app is started. On subsequent runs, we simply check if the serialized script already exists and check to make sure the bundle has not been updated since the serialized bytecode bundle was produced. If both of those conditions are satisfied, we use the JsRunSerializedScriptWithCallback API to run the bytecode bundle.

We found that the bytecode bundle was more performant than any other approach for the test app.

Bundle LoaderAverage Load Time (ms)
C# Regular Bundle (MB)297
C# File-Based Unbundle (MU)1695
C# Indexed Unbundle (MI)290
C++/CX Regular Bundle (MB)249
C++/CX File-Based Unbundle (MU)257
C++/CX Indexed Unbundle (MI)229
C++/CX Serialized Bundle (NS)143

There are a few limitations to the bytecode bundle. First is that the serialized bytecode needs to be generated the first time the app runs, and each time the bundle is updated with tools like CodePush. For apps with intermittent use that push regular updates, this could mean that you’ll be paying the penalty to generate the bytecode bundle each time the app is started, resulting in no net performance benefit to your users. Also, the bytecode bundle approach is not currently compatible with the unbundle approach. More complex apps with large bundle sizes may actually see better performance with the indexed unbundle.

Conclusions and Future Work

By default, react-native-windows still uses the C# IJavaScriptExecutor, meaning you’ll have to make some changes to the native code in your app to start using the C++/CX executor. If you use the React Native CLI tool to generate your react-native and react-native-windows projects, then you’ll simply need to override the JavaScriptExecutorFactory property on the MainPage.cs class that is generated.

 

You can use this code story as a baseline for your decision on what kind of bundle to use in React Native. Ultimately, for the trivial UWP test app, we found that the serialized bytecode bundle produced the fastest load times. For less trivial apps with much larger bundle sizes, it may be the case that the indexed unbundle would be faster. Similarly, for apps with regular updates and intermittent use, the cost of regenerating the bytecode bundle may exceed its potential benefits. There are many variables to app load times both with and without unbundles. Some apps may require a large number of modules just to render the first component. Some apps may not want to pay an on-demand module loading penalty at a critical point in the app. You will likely need to run an experiment similar to the one described above to make this decision for your app.

We still need to implement the serialized bytecode approach for WPF, as it’s currently only implemented in the C++/CX project, which will only work for WinRT apps. We can do this either by porting the C++/CX project to C++/CLI, or by exposing the JsSerializeScript and JsRunSerializedScriptWithCallback APIs via P/Invoke to the C# IJavaScriptExecutor. We also would like to implement behaviors to serialize the JavaScript bytecode bundle on a background thread, so we don’t pay as much of a penalty for the bytecode serialization each time the bytecode needs to be regenerated.

For further implementation details on either the C# or C++/CX unbundle solution, check out the unbundle pull request on react-native-windows.

If you’re interested in getting started with react-native-windows, check out our code story on how to build UWP apps with react-native-windows.

Leave a reply

Your email address will not be published. Required fields are marked *