Since we announced Hermes in 2019, it has been increasingly gaining adoption in the community. The team at Expo, who maintain a popular meta-framework for React Native apps, recently announced experimental support for Hermes after being one of the most requested features of Expo. The team at Realm, a popular mobile database, also recently shipped its alpha support for Hermes. In this post, we want to highlight some of the most exciting progress we've made over the past two years to push Hermes towards being the best JavaScript engine for React Native. Looking forward, we are confident that with these improvements and more to come, we can make Hermes the default JavaScript engine for React Native across all platforms.
Optimizing for React Native
Hermes’s defining feature is how it performs compilation work ahead-of-time, meaning that React Native apps with Hermes enabled ship with precompiled optimized bytecode instead of plain JavaScript source. This drastically reduces the amount of work needed to start up your product for users. Measurements from both Facebook and community apps have suggested that enabling Hermes often cut a product’s TTI (or Time-To-Interactive) metric by nearly half.
That being said, we’ve been working on improving Hermes in many other aspects to make it even better as a JavaScript engine specialized for React Native.
Building a New Garbage Collector for Fabric
With the upcoming Fabric renderer in the new React Native architecture, it will be possible to synchronously call JavaScript on the UI thread. However, this means if the JavaScript thread takes too long to execute, it can cause noticeable UI frame drops and block user inputs. The concurrent rendering enabled by React Fiber will avoid scheduling long JavaScript tasks by splitting rendering work into chunks. However, there is another common source of latency from the JavaScript thread — when the JavaScript engine has to “stop the world” to perform a garbage collection (GC).
The previous default garbage collector in Hermes, GenGC, was a single-threaded generational garbage collector. The new generations uses a typical semi-space copying strategy, and the old generations uses a mark-compact strategy to make it really good at aggressively returning memory to the operating system. Due to its single-thread, GenGC has the downside of causing long GC pauses. On apps that are as complicated as Facebook for Android, we observed an average pause of 200ms, or 1.4s at p99. We have even seen it be as long as 7 seconds, considering the large and diverse user base of Facebook for Android.
In order to mitigate this, we implemented a brand new mostly concurrent GC named Hades. Hades collects its young generation exactly the same as GenGC, but it manages its old generation with a snapshot-at-the-beginning style mark-sweep collector. which can significantly reduce GC pause time by performing most of its work in a background thread without blocking the engine’s main thread from executing JavaScript code. Our statistics show that Hades only pauses for 48ms at p99.9 on 64-bit devices (34x faster than GenGC!) and around 88ms at p99.9 on 32-bit devices (where it operates as a single-threaded incremental GC). These pause time improvements can come at the cost of overall throughput, due to the need for more expensive write barriers, slower freelist based allocation (as opposed to a bump pointer allocator), and increased heap fragmentation. We think those are the right trade-offs, and we were able to achieve overall lower memory consumption via coalescing and additional memory optimizations that we’ll talk about.
Striking on Performance Pain Points
Startup time of applications is critical to the success of many apps, and we are continuously pushing the boundary for React Native. For any new JavaScript feature we implement in Hermes, we carefully monitor their impact on production performance and ensure that they don’t regress metrics. At Facebook, we are currently experimenting with a dedicated Babel transform profile for Hermes in Metro to replace a dozen Babel transforms with Hermes’s native ESNext implementations. We were able to observe 18-25% TTI improvements on many surfaces and overall bytecode size decreases and we expect to see similar results for OSS.
In addition to startup performance, we identified memory footprint as an opportunity for improvement in React Native apps especially for virtual reality. Thanks to the low-level control we have as a JavaScript engine, we were able to deliver rounds of memory optimizations by squeezing bits and bytes out:
- Previously, all JavaScript values were represented as 64-bit NaN-boxing encoded tagged values to represent floating point doubles and pointers on 64-bit architecture. However, this is wasteful in practice because most numbers are small integers (SMI) and JavaScript heap of client-side applications is not expected to be larger than 4GiB generally. To address this, we introduced a new 32-bit encoding in which SMI and pointers are encoded in 29 bits (because pointers are 8-byte aligned, we can assume the bottom 3 bits are always zero), and the rest of JS numbers are boxed onto the heap. This reduced the JavaScript heap size by ~30%.
- Different kinds of JavaScript objects are represented as different kinds of GC-managed cells in the JavaScript heap. By aggressively optimizing the memory layout of headers for those cells, we are able to reduce memory usage by another ~15%.
One of our key decisions with Hermes was to not implement a just-in-time (JIT) compiler because we believe that for most React Native apps, the additional warm-up costs and extra footprints on binary and memory would not actually be worthwhile. For years, we invested a lot of effort in optimizing interpreter performance and compiler optimizations to make Hermes’s throughput competitive with other engines for React Native workloads. We are continuing to focus on improving throughput by identifying performance bottlenecks from everywhere (interpreter dispatch loop, stack layout, object model, GC, etc.). Expect some more numbers in upcoming releases!
Pioneering at Vertical Integrations
At Facebook, we prefer to colocate projects within a large monorepo. By having the engine (Hermes) and the host (React Native) closely iterating together, we opened a lot of room for vertical integrations. To name a few:
- Hermes supports on-device JavaScript debugging with the Chrome debugger by speaking the Chrome DevTools Protocol. It’s better than the legacy “Remote JS Debugging” (which uses an in-app proxy to run JS in desktop Chrome) because it supports debugging synchronous native calls and guaranteed a consistent runtime environment. Together with React DevTools, Metro, Inspector, and so on, Hermes debugger is now part of Flipper to provide a one-stop developer experience.
- Objects allocated during the initialization path of React Native apps are often long-lived and don’t follow the generational hypothesis leveraged by generational GCs. Therefore, we configured Hermes in React Native to allocate the first 32MiB directly into old generations (known as pre-tenuring) to avoid triggering GC pauses and delaying TTI.
- The new React Native architecture is heavily based on JSI (or JavaScript Interface), a lightweight, general-purposed API for embedding a JavaScript engine into a C++ program. By having the team maintaining the JS engine also maintains the JSI API implementation, we are confident in providing the best possible integration that is reliable, performant and battle-tested at the Facebook’s scale.
- Getting JavaScript concurrency primitives (e.g. promises) and platform concurrency primitives (e.g. microtasks) both semantically correct and performant are critical to React concurrent rendering and the future of React Native apps. Historically, promises in React Native were polyfilled using non-standardized
setImmediate
APIs. We are working on making native promises and microtasks from JS engines available via JSI, and introducingqueueMicrotask
, a recent addition to the web standard, to the platform, to better support modern asynchronous JavaScript code.
Bringing Along the Whole Community
Hermes has been really great for us at Facebook. But our work is not done until our community can use Hermes to power experiences throughout the ecosystem, so that everyone leverage all of its features and to embrace its full potential.
Expanding to New Platforms
Hermes was initially open sourced only for React Native on Android. Since then, we have been thrilled to see our members of the community expanding Hermes support into many other platforms that React Native’s ecosystem has expanded.
Callstack led the effort of bringing Hermes to iOS in React Native 0.64. They wrote a series of articles and hosted a podcast on how they achieved it. According to their benchmarks, Hermes was able to consistently deliver ~40% improvement to startup and ~18% reduced memory on iOS compared to JSC for the Mattermost app, with only 2.4 MiB of app size overhead. I encourage you to see it live with your own eyes.
Microsoft has been bringing Hermes to React Native for Windows and macOS. At Microsoft Build 2020, Microsoft shared that Hermes’s memory impact (working set) is 13% lower than the Chakra engine on React Native for Windows. Recently, on some synthetic benchmarks, they’ve found Hermes 0.8 (shipped with Hades and aforementioned SMI and pointer compression optimization) uses 30%-40% less memory than other engines. Not surprisingly, the desktop Messenger video calling experience built on React Native, is also powered by Hermes.
Last but not least, Hermes has also been powering all virtual reality experiences built with the React family of technologies on Oculus, including Oculus Home.
Supporting our Community
We acknowledge there are still blockers that prevent parts of the community from adopting Hermes and we are committed to building support for these missing features. Our goal is to be fully featured so that Hermes is the right choice for most React Native apps. Here is how the community has already shaped the Hermes roadmap:
Proxy
andReflect
were originally excluded from Hermes because Facebook does not use them. We were also concerned that adding Proxy would hurt property lookup performance even when Proxy is not used. But Proxy quickly become the most requested feature of Hermes due to popular libraries such as MobX and Immer. We carefully evaluated and decided to build it just for the community, and we managed to implement it with very low cost. Since this is a feature we don’t use, we relied on our community to prove its stability. We started by testing Proxy behind a flag and created opt-in npm packages for release v0.4 and v0.5, and it’s enabled by default starting from v0.7.- ECMAScript Internationalization API Specification (ECMA-402, or
Intl
) was the second most requested feature.Intl
is a huge set of APIs and often requires the implementation to include 6MB worth of Unicode CLDR data. This is why polyfills like FormatJS (a.k.a.react-intl
) and JS engines like the international variant build of community JSC are so huge. To avoid substantially increasing the binary size of Hermes, we decided to implement it with another strategy by consuming and mapping the ICU facilities provided by the libraries included in the operating systems, at the cost of some (often minor) variance in behaviors across platforms.- Microsoft collaborated to build support on Android. It covers almost everything from ECMA-402 up to ES2020, with only a size impact as small as 3% (57-62K per ABI). We ran a poll on Twitter and the results were strongly in favor of including
Intl
by default, so that’s what we did and it’s available starting from release v0.8. - Facebook has sponsored Major League Hacking to launch a remote open source fellowship program. Last year, we launched the Hermes sampling profiler. This year, our fellows will be working with members from Hermes, React Native, and Callstack, to add support for Hermes
Intl
on iOS. Stayed tuned!
- Microsoft collaborated to build support on Android. It covers almost everything from ECMA-402 up to ES2020, with only a size impact as small as 3% (57-62K per ABI). We ran a poll on Twitter and the results were strongly in favor of including
- We appreciate that people have been working with us to discover issues affecting the community.
- People have helped us identify critical spec divergence such as stability of
Array.prototype.sort
amended in ES2019. This has been fixed and will be available in the next release. - People found out that our default heap size limit was too small and caused unnecessary GC pressure and OOM crashes for many users who are not familiar with customizing Hermes’s GC configs. So we increased it from 512MiB to 3GiB to be more than sufficient for most users by default.
- People also reported that our specialized
Function.prototype.toString
implementation caused performance to drop in libraries doing improper feature detection and blocked users from doing source code injecting. This helped us strengthen our stance that Hermes, whenever possible, should not get in the way of developers and to respect de-facto practices.
- People have helped us identify critical spec divergence such as stability of
Summary
In summary, our vision is to make Hermes ready to be the default JavaScript engine across all React Native platforms. We’ve already started working towards it, and we want to hear from all of you about this direction.
It’s extremely important for us to prepare the ecosystem for a smooth adoption. We encourage you to try out Hermes, and file issues on our GitHub repository for any feedbacks, questions, feature requests and incompatibilities.
Thanks
We’d love to thank the Hermes team, the React Native team, and the many contributors from the React Native community for their work to improve Hermes.
I’d also love to personally thank (in alphabetic order) Eli White, Luna Wei, Neil Dhar, Tim Yung, Tzvetan Mikov, and many others for their help during the writing.