How Airbnb Smoothly Upgrades React
Airbnb’s frontend recently reached a major milestone: all of our web surfaces have been upgraded from React 16 to React 18, the current major version of React¹. This was a big project for a product with many surfaces, including Guest and Host pages as well as many internal tools. To safely perform this upgrade, we created the React Upgrade System: reusable infrastructure that allows us to roll out new versions of React progressively across our monorepo and measure the results of the upgrade. In this blog post, we’ll discuss our upgrade philosophy, the system we created, and what we learned from performing this upgrade.
While this post primarily focuses on React, the system and lessons are applicable to many web frameworks and libraries that require regular upgrades.
Challenges of upgrading
Upgrading dependencies is a common task in any long-lived project. Upgrades fix bugs, improve performance, and unlock new APIs. Some upgrades are simple, but upgrades become more difficult when large amounts of product code rely on changed APIs or subtle assumptions about behavior. In Airbnb’s web monorepo, we only allow one version of each top-level dependency (with some rare exceptions), with one package.json at the root of the repo. This ensures that code within the monorepo is internally compatible and consistent, and that we avoid shipping duplicate packages to users. Before the upgrade system, having a single version of each dependency meant performing an atomic update, which requires a huge amount of up-front migration work, a long-running upgrade branch, and a single milestone when it is finally deployed to users. Such an approach is error-prone and risky, thus requiring a “heroic” engineering effort to ship clean upgrades.
Ideally, we’d be shipping small, incremental upgrades that have no issues. Without some way to test and progressively roll out this system to a large monorepo, we often needed to try upgrading multiple times, downgrading whenever any problems were found. Performance regressions were particularly difficult to catch using this upgrade strategy. Because there was no way to collect performance data prior to release, we went straight from 0% to 100% rollout on deployment.
Ideal vs Reality graphs of our major and minor versions of React over time.
Our goal with the React Upgrade System was to make more seamless upgrading less heroic and more routine. Specifically, our goals were to be able to:
- Upgrade incrementally so that we get feedback and learn lessons as soon as we can.
- Upgrade often so that the delta between our version and the upgraded version is as small as possible.
- Test upgrades so that we can precisely measure the performance impact of upgrades and make informed decisions about upgrade paths using this data.
Designing the React Upgrade System
Working backwards from these goals, we started to get an idea of what our ideal architecture would look like. We wanted to avoid a long-running upgrade branch so that we could upgrade incrementally, and we wanted to be able to A/B test the upgrade so that we would get feedback from production to inform shipping decisions.
Simplified diagram of our ideal upgrade system
There were a couple of problems to solve with the most naive implementation of this system: we needed to pick a single version of React for rendering, and it was challenging to dynamically switch between the two versions at runtime. Here’s what the code would look like to render a basic application using this naive approach:
import React18 from 'react';
import React16 from 'react';
if (shouldEnableReact18()) {
const root = React18.createRoot(container);
root.render(<App />);
} else {
React16.render(<App />, container);
}
There are two issues with this:
- We don’t want to bundle both versions of React in the application, or we’ll double our framework bundle size. Further, we might need to change the JSX transformation being used at build time, making our
<App />
incompatible with one version or the other. - It’s not clear where the imports should come from. The ‘react’ dependency will point to either React 16 or React 18, but not to both.
To solve these problems, we used module aliasing to split the versions, and environment targeting to build and run the two split versions of React.
Module aliasing
We addressed the problem of where these imports are coming from using module aliasing. Using yarn, we added another react dependency to our package.json, e.g.,
"react-18": "npm:react@18"
which allowed us to import React from the ‘react-18’ package. This got us part of the way there. Many tools (such as custom resolvers and build systems) need to know which of the two versions to use. To centralize the logic, we wired up all of our custom tooling into a central, “global alias” configuration. This global alias configuration allowed us to alias in one place for all of our different tools. Babel, Jest™, Webpack™, and other custom resolution logic all need to be aware of the conditions under which we want to redirect imports from ‘react’ to ‘react-18’. Aliasing the modules with our “global alias” configuration meant that user code did not need to change at all, and we were able to handle this redirect behind the scenes.
TypeScript discrepancies
Given that any component could be run in React 16 or 18, we wanted to use the types for each component that work across both versions during our upgrade period. Thankfully, the React team has maintained backwards compatibility, even between major versions.
We installed the types for React 18, and for newly added APIs in React 18, we created a shim layer for these APIs that worked in both React 16 and 18 (for example, useTransition acted as a no-op in 16). For APIs with no possible shim (for example, useId), we indicated through type augmentation that this hook may be undefined at runtime.
For TypeScript-only breaking changes in React 18, we waited until the React 18 upgrade was complete before incrementally fixing these. We augmented the types to patch differences to allow progressively fixing these new Typescript errors in our monorepo.
Environment targeting
To solve the problem of duplicated imports, we needed to produce two different build artifacts: one containing React 16 and one containing React 18. Let’s call these the “control” and “treatment” artifacts, respectively. Since Airbnb uses Server-Side Rendering (SSR), we also needed to run these two different artifacts in different node processes on the server. Using Kubernetes®, we set up two different Kubernetes environments that ran these control and treatment artifacts. Let’s call this setup environment targeting.
Module aliasing and environment targeting in use together to deploy different versions of the framework together in production
We also wrote an environment variable (REACT_UPGRADE) to our assets at build time and set this variable at runtime in our node SSR service. This lets us perform conditional logic that might be necessarily on only one or the other side of our upgrade system.
This setup also worked for us in local development. Our “local” development environments were also deployed, so we were able to configure the React version for local development in the same way as production using this setup. As each SSR service was upgraded to React 18, we also switched the development environment for that service to React 18 to keep production and local development versions synchronized.
Testing the upgrade
Airbnb has a comprehensive test suite, which was helpful for building confidence in the safety of this upgrade before exposing the upgrade to users. Our test suite includes visual regression testing, integration testing, and unit testing. Before launching to users, we fixed all new failures in each of these suites.
Unit tests were the hardest to abstract from framework internals. Because we use a combination of Enzyme and React Testing Library, we needed to fix assumptions about APIs and framework internals in unit tests, shims, and adapters. To achieve this, we ran all of our unit tests under both React 16 and 18, allowing existing failures in the React 18 test suite as we progressively fixed them. We used this “permitted failures” list to ratchet down the number of test failures over time, which prevented backsliding, as no new failures were allowed on the list. This approach allowed us to fix problems incrementally with components and our test environments.
We tracked the work of resolving hundreds of test failures with dashboards, merged fixes incrementally using the upgrade system, and split the work amongst a handful of developers. This made the migration work largely transparent to the broader frontend team and helped us gain confidence in the upgrade before rollout.
Progressive rollout
Once we had module aliasing and environment targeting, we had the capability to author and deliver code for two different versions of React, all from the same codebase. To ensure safety and testability, we also needed a way of rolling out this new environment progressively. To reduce the amount of change happening at once, we wanted to control the roll out across traffic and product surfaces. Our experimentation infrastructure allowed us to direct traffic to each of our two production environments (control and treatment) at will. This setup also allowed us to test the upgrade internally first, and to completely turn off the upgrade if issues were found.
Controlling the rollout to different surfaces is more difficult. Within a Single Page App, managing multiple React versions would mean unmounting and mounting React roots. This would lead to poor performance and degrade the user experience.
For this reason, we managed the surface rollout upgrade at the app level. Airbnb’s monorepo houses many Single Page Apps, so it was useful to have the react upgrade system in place to be able to turn the upgrade on and off for each of these apps. Using our React Upgrade System, we were able to roll this out to a single app internally first, giving developers a way to opt-in and opt-out of the upgrade for testing, in both development and on our staging sites. This approach let us avoid having a long running feature branch, helping us achieve our goal of incremental upgrades.
Feature adoption and future work
Using the system, we completely rolled out React 18 to all web surfaces at Airbnb, with no rollbacks required. After the upgrade, we were able to start testing new APIs such as new root APIs and concurrent rendering features. We intentionally held off for a few weeks on adopting these features until the upgrade had settled. This way we could be confident that we wouldn’t need to downgrade and have to revert code changes.
It’s been exciting to see performance improvements from adopting these new features, and we are continuing to experiment with expanding them to key UI surfaces that would benefit.
To ensure that our goal of upgrading is often met, we will use the React Upgrade System to test the canary channel of React. Instead of pointing to React 18, we can just point at the canary tag and get a preview of what migration work needs to be happening now for React 19. To make upgrading not require a heroic effort, staying current should be a continual effort spread out over time, rather than a large, one-off change.
Conclusion
Our goals for the React Upgrade System were to enable us to upgrade incrementally, test upgrades, and upgrade often. Combining environment targeting and our aliasing system has allowed us to upgrade incrementally and test the upgrades. We’re beginning to run our frontend against React 19 beta, getting a head start on React 19.
We’d like to acknowledge the React team for putting effort into backwards compatibility between React versions, even major versions. Without that effort, this upgrade approach would not be possible.
Using the React Upgrade System, we gained confidence in our rollout of React 18, and will use this approach for future upgrades. We believe investing in an upgrading system is worthwhile, as upgrades will continue to be needed over time. The React Upgrade System has allowed us to test and roll out upgrades incrementally, ensuring that we’re delivering the best user experience and performance possible for our users.
If this kind of work sounds appealing to you, check out our open roles — we’re hiring!
Acknowledgments
Many thanks to Joshua Nelson for leading the effort to build the React Upgrade System as well as for drafting this blog post.
Additionally, thanks to Kim Nguyen, Callie Riggins Zetino, James Robinson, Dan Beam, Kaeson Ho, Rae Liu, Michael James, Noah Sugarman, Laurie Jin, Brie Bunge, Matt Mulder, Victor Lin for their assistance on this system and the pieces comprising it.