With Hermes, React Native lives in the same league as Swift and Kotlin

Mathew Pregasen
Mathew Pregasen
Guest Writer

Apr 25, 2023

What is Hermes?

Hermes is a JavaScript engine optimized for React Native. It completely reworks how React Native applications are compiled, as well as how they run on devices natively. Particularly, Hermes addresses a challenge that has plagued React Native applications since its introduction: unlike Swift or Kotlin-powered apps, React Native apps had to compile at runtime (JIT compilation). Unlike its predecessor, JavaScriptCore (JSC), Hermes is built around an ahead-of-time (AOT) compilation strategy that generates massive performance gains.

In 2019, Meta announced Hermes and open-sourced it to the React Native community. In 2022, Hermes was shipped as the default engine alongside React Native updates, replacing JSC.

Hermes has been key in making React Native a more competitive native framework that can contend with Swift or Kotlin. Here, we’ll discuss the Hermes predecessor, Hermes’s design decisions, and how Hermes could impact React Native long term.

Understanding JavaScriptCore (JSC)

There are two ways to understand the issue with JSC: an empirical case study and a high-level paradigm exploration.

An empirical historical example

Meta created React Native to consolidate development patterns with its main React web application. Meta uses React Native for most of its mobile applications, including its flagship app, which includes Facebook Marketplace.

Marketplace is built entirely with React Native. But because Marketplace displays countless products to users—and by extension, has easily hundreds of subviews—it historically took a long time to load. This phenomenon is known as having a poor TTI, or time-to-interact.

But loading wasn’t the only issue. Marketplace struggled with garbage collection (the process of freeing memory no longer used by the application). As users scrolled Marketplace quickly, a massive garbage collection job would eventually cause the app to freeze. This “stop the world” (STW) effect and the often laggy experience tanked user sentiment around Marketplace.

To be fair, some users never noticed a thing. If they were running Facebook Marketplace on the newest phone models, their powerful hardware often hid the inefficiencies by working really hard to compensate for the app’s deficiencies. But Facebook is a massive product used across the globe, and for devices with more limited hardware, these issues were make-or-break matters against a positive user experience.

Something had to change.

Eliminating the inherent awkwardness of React Native

A mismatched engine was the center of React Native’s issues. React Native applications are designed to run natively, but were using an engine (JavaScriptCore) designed for any arbitrary application. This means that React Native had long missed a crucial element of nativeness by not taking advantage of the guarantee that the target device would run the application directly on the operating system. Hermes grants React Native something that’s otherwise a core native attribute: the heavy lifting is done at build time, not run time.

Understanding the new Hermes flow

The JavaScript pipeline

Hermes doesn’t dramatically change the pipeline used by JSC —it just rearranges it. To best understand this shift, let’s explore the steps from compilation to execution.

  • Transpilation: JavaScript source must be transpiled into a standardized syntax (such as ECMAScript 2015). Because JavaScript has various experimental features with some syntactical differences, transpilation transforms any arbitrary specification into the target specification. Often developers will use new JavaScript features and then transpile code to an older specification, using products like Babel.
  • Minification: Minification shrinks the JavaScript bundle by renaming variables into shorthand names of single or double letters. This dramatically condenses the bundle size by removing any excess code.
  • Parsing: Now the JavaScript engine kicks in. It must parse the functions from the JavaScript bundle. Parsing involves several sub-steps, such as (i) finding the function calls, (ii) locating the function definitions, and (iii) optimizing the function structure.
  • Bytecode: Then the JavaScript engine must compile the JavaScript into bytecode, an intermediary representation that looks like assembly. Bytecode is a semi-readable format by humans that gets compiled into binary instructions for the physical or virtual hardware to run.
  • Execution: Finally, the JavaScript engine executes that bytecode. The remaining work is handled by the operating system and hardware.

The old JSC pipeline

Before Hermes, a JSC pipeline was evenly split between build time and run time.

JSC would expect a transpiled and minified JavaScript bundle through a React Native compiler or an analog like Expo Build. Then, JSC would handle the rest at run time when the user opened the application. This rendered a visibly slow start to the end user.

The new Hermes pipeline

Hermes moves almost all processes to build time instead.

Now, parsing functions, compiling bytecode, and optimizing bytecode happen on a developer’s computer once; since developers’ computers these days are pretty beefy, this is a better allocation of resources. Afterward, end user devices just load and execute that bytecode at runtime.

How Hermes accomplishes AOT compilation

Hermes achieves ahead-of-time (AOT) compilation through a few intermediate steps.

First, Hermes compiles the transpiled JavaScript into Static Single Assignment Intermediate Representation (SSA IR). Bytecode is an intermediate representation (IR), but SSA IR keeps JavaScript’s semantics which gives Hermes developers more visibility into the intermediary process. Hermes refers to SSA IR as a High-Level IR. It handles register allocation, labels side effects on memory and I/O, and allocates space on the heap for frame variables generated by JavaScript function closures.

Then, Hermes compiles SSA IR into optimized bytecode designed for the device. Because bytecode is optimized and succinct, Hermes can often ship a much smaller bundle size on Android devices. (iOS bundles are already fairly minified, so Hermes actually marginally increases the bundle size.)

To be clear, to most developers, IR is something of a foreign concept. But IR enables developers to write the JavaScript they want to write. In the background, IR handles the complex process of making that JavaScript efficient to run on the target device.

The Hermes garbage collector, Hades

One of the biggest changes Hermes introduces to the JavaScript ecosystem is its new garbage collector.

In earlier versions, Hermes used JSC’s original garbage collector, GenGC (Generational Garbage Collector). GenGC split memory segments into two generations, a Young Generation (YG) and an Old Generation (OG). The YG would go through an efficient process, where memory was pruned if it showed obvious signs that the segment was freed. The remaining allocated memory in YG was then allocated to the OG. The OG would run through Cheney’s algorithm, a CPU-consuming process that split the heap, ping-ponged memory between halves, and trimmed it down in stages. Because GenGC is single-threaded, running on the same thread as the application’s main JS interpreter, its OG process would freeze up complex apps. creating an STW side effect.

Hermes’s own garbage collector, Hades, extends GenGC’s YG and OG, but radically re-engineers how the OG works. Now OG undergoes a sequence of stages—Mark, Sweep, and Compact—which more accurately frees up memory by leveraging various data structures and locks. Even more crucially, Hades’s OG runs concurrently with the main interpreter thread. As a result, OG doesn’t freeze the main application—and given that OG was always more CPU-intensive than YG, this makes a monumental difference over GenGC.

There’s a downside to this approach: because Hades runs OG in a background thread and implements a more involved algorithm, memory is freed more slowly. On the other hand, because Hades is more accurate than GenGC, more memory is ultimately freed. The Hermes team contends that the performance improvements of Hades overwhelm the entry-to-exit speed of segments undergoing garbage collection. And given Hermes’s overall goal of targeting better application performance, this prioritization of minimizing freezes and decreasing net memory consumption is aligned with its design philosophy.

How successful is Hermes?

Hermes has put up some impressive benchmarks that demonstrate its success. In particular, Meta’s benchmarks for Hermes averaged as follows:

  • Time To Interact (TTI): 51% decrease on Android and 63% decrease on iOS
  • Build Size: 19% decrease on Android but a 19% increase on iOS (noting that iOS build sizes were typically quite smaller than Android build sizes, so the tradeoff is a net positive.)
  • Memory Utilization: 23% decrease on Android and 11% decrease on iOS

Additionally, while it can be difficult to measure STW memory effects scientifically since they happen sporadically, Hermes almost eradicates the issue with Hades moving the OG process to a background thread.

Hermes’ compatibility with the existing developer ecosystem

Hermes has been widely embraced by developers and the greater React Native developer ecosystem.

For example, Expo, a popular React Native framework with a robust testing suite, has also shifted to Hermes being its default engine. Expo apps have shown similar improvements to app metrics since the switch.

Hermes also now works with MobX and Immer, which are popular state management libraries used by React Native developers.

And Hermes used to be exploitable to run Doom—yes, the legendary 1990s video game— which is basically a rite-of-passage to become a respected developer product!

Does Hermes have any limitations?

While Hermes has largely had positive impacts, it lacks certain features defined by its target specification, ECMAScript 2015. However, most missing features either don’t make sense for React Native, are minor, or can be handled by a transpiler like Babel.

Previously, some of these limitations restricted developers from using Hermes altogether. Asynchronous features like async and await originally weren’t supported, but they have since been added. (The official documentation is still a bit outdated at the time of writing.) BigInt and WeakRef, which were initially unavailable, were made available in 0.70, providing support to a lot of applications that deal with large numbers or are memory-conscious. Others, like let and const remain unsupported but, practically, are supposed by Hermes’ dedicated Babel transform profile.

Two of the most popular unsupported features were Proxy and Reflect. Previously, Meta expressed reluctance to support these, concerned that they could slow Hermes down overall. But popular state libraries like MobX and Immer depend on Proxy and Reflect—andReact Native 0.70 shipped with a Hermes engine that supported both by default.

Today, some JavaScript elements still remain unsupported. Some are syntactical, like with statements or the constructor property. Others don’t make as much sense for React Native to begin with, such as Realms, which are what enable multiple <script> tags to work together seamlessly in browsers.

Overall, after a few updates post-launch, Hermes now supports a majority of ECMAScript 2015 features that are relevant to React Native developers.

Conclusion

Hermes signals React Native’s willingness to prioritize the advantages of writing for native targets over JavaScript’s semantics designed for browsers. For the longest time, React Native was significantly less performant versus native Swift and Kotlin applications, which limited its growth for performance-sensitive products. Now, Hermes makes React Native a serious contender for building complex applications.

Reader

Mathew Pregasen
Mathew Pregasen
Guest Writer
Apr 25, 2023
Copied