Airbnb's Trip to Linaria
CSS is a critical component of every web application, and many solutions have evolved for how styles are written by developers and delivered to visitors. In this post we’ll take you through Airbnb’s journey from Sass to CSS-in-JS and show you why we landed on Linaria, a zero-runtime CSS-in-JS library, and the impact it has had on the developer experience and performance of Airbnb’s web app.
From Sass to CSS-in-JS
In 2016, our web frontend was in a monolithic Ruby on Rails app using a combination of Sprockets, Browserify, and Sass. We had a Bootstrap-inspired internal toolkit for styling, but we weren’t using anything like CSS Modules or BEM.
Production bugs were often caused by our styling — sometimes the correct stylesheet was missing from some pages and other times styles from different stylesheets conflicted unexpectedly.
Additionally, developers rarely removed styles once added since it was hard to know whether they were still needed. These issues compounded as our product surface area rapidly expanded.
As we began to build our Design System in React, we landed on CSS-in-JS as an exciting new option. At the time, CSS-in-JS was still in its infancy–only a few libraries existed and Styled Components had not been invented yet. We chose Aphrodite, but didn’t want to be directly coupled to Aphrodite’s implementation for two reasons: since CSS-in-JS was a nascent space we wanted to have the flexibility to switch implementations at a later date, and we also wanted something that would work for open source projects where people might not want Aphrodite. So we created an abstraction layer called react-with-styles, which gave us a higher-order component (HOC) to define themeable styles.
This allowed components to be styled in the same file, making repo organization more convenient. More importantly, moving from a globally-aware styling system to a component-based styling system gave us guarantees around how styles would be applied and what files were needed to render every component correctly on every page. This enabled us to rely on Happo, our screenshot testing tool of choice, and as a result visual regressions plummeted (disclosure: I am the co-creator of Happo).
Though react-with-styles has served us well for years, it comes with performance and developer experience tradeoffs. The styles and runtime libraries increase critical path JS bundle size, and applying styles at render-time comes with a CPU cost (10–20% of our component mount times). While we get the aforementioned guarantees about styles, actually writing styles in JavaScript objects feels awkward compared to regular CSS syntax. These tradeoffs led us to reconsider how we style the web at Airbnb.
Considering Our Options
To address the problems with react-with-styles, we formed a working group of engineers from various teams. We considered a number of directions, which fit into the following high-level categories:
- Static extraction of CSS from react-with-styles at build time
- Write our own framework
- Investigate and adopt an existing framework
We decided against static extraction from react-with-styles at build time because it would require a lot of effort. Additionally, it would be home-grown and therefore lack benefits of a community. Finally, it does not address developer ergonomics issues.
Similarly, writing our own framework would have had a high cost of initial implementation, maintenance, and support. Additionally, there were existing solutions for this problem that we wanted to leverage and contribute back to.
Comic from https://xkcd.com/927/ by Randall Munroe and is used under a CC-BY-NC 2.5 license.
After evaluating several existing frameworks against our requirements, we narrowed down candidates for building a proof of concept:
- Emotion: CSS-in-JS, with a low runtime cost
- Linaria: zero-runtime CSS-in-JS (static CSS extraction)
- Treat: near zero-runtime CSS-in-JS (static CSS extraction)
The proof-of-concepting work was done in a new repo that implemented a server-rendered client-hydrated unstyled version of Airbnb’s logged in homepage. For each framework, this allowed us to:
- Understand what changes might need to be made to our build system
- Try out framework APIs and get a feel for developer ergonomics
- Assess how each framework supports our web styling requirements
- Gather performance metrics
- Serve as a starting point for a migration plan
Frameworks were evaluated against each other based on the following ranked list of criteria:
- Performance
- Community (i.e. support and adoption)
- Developer experience
Performance Analysis
Using SpeedCurve, local benchmarking, and the React <Profiler />, we ran performance benchmarking tests for each framework. All results were calculated as the median of 200 runs on a throttled MacBook Pro, and are statistically significantly different from control with a p-value of <= 0.05.
Informed by Airbnb’s Page Performance Score (similar to Lighthouse’s performance score), we focused on the following metrics to give us an idea of how each framework performed and would impact the user experience:
It is clear that the frameworks are divided into two groups: runtime frameworks (react-with-styles, Emotion) and build-time frameworks (Linaria, Treat).
Benchmarks of the server-rendered and client-hydrated version of our homepage showed Treat and Linaria performing 36% and 22% better than Emotion on Total Blocking Time, respectively. All frameworks performed significantly better than react-with-styles, ranging from a 32–56% improvement. (Note that these numbers should not be used to estimate expected improvements in production, as this is a very specific benchmark designed to test differences between frameworks, not expected savings in production.)
Bundle size differences also fall into these two categories — with savings on the order of 80 KiB (~12%) for the Linaria/Treat group.
The CSS metrics (update layout tree and composite layers) show that, on average, there is roughly one more layout tree update and layer composition event for react-with-styles/Emotion. This is likely due to the insertion and hydration of stylesheets with JavaScript that is not necessary with a CSS extraction library like Linaria or Treat.
This performance investigation shows that either Linaria or Treat would be promising options to adopt, and that all frameworks considered are a statistically significant improvement over react-with-styles with Aphrodite.
What We Liked About Linaria
The above performance improvements were largely thanks to Linaria extracting the styles from JS to static CSS files at build time, so there is no JS bundle or runtime CPU overhead — giving it a slight edge over the near-zero runtime Treat. Also, this brings caching benefits since these static CSS files may change at a different cadence than the JS files. Since the styles are extracted at build time, Linaria has the opportunity to automatically remove unused styles — this also opens the door to the possibility of deduplicating styles (i.e. Atomic CSS). Additionally, Linaria supports injecting the critical CSS for server-side rendering, which we had wanted to preserve from our react-with-styles integration.
Linaria also seemed to be a healthy project that saw a good amount of activity, community involvement, documentation, and adoption. Its good trajectory gave us confidence that it would continue to improve and that we would be able to contribute back.
We found Linaria’s tagged template literal API that enables developers to use CSS syntax to be an attractive improvement over the JS object HOC API that we built for react-with-styles. Additionally, off-the-shelf integrations were available for stylelint, CSS autocompletion, and syntax highlighting, which enriched the developer experience.
Off-the-shelf integrations for stylelint, CSS autocompletion, and syntax highlighting working with Linaria in action.
We also found value in the similarities between Linaria and our existing solution. The co-location of styles within the component file was a big feature that tipped the scales in favor of Linaria over Treat for us, and the familiar API smoothed the transition for developers and gave us confidence that migration efforts could be eased with automation.
Migration Strategy
To roll out this big change, we adopted an incremental migration strategy that is largely automated by codemods we’ve written. We are leaning heavily on our Happo screenshot tests to ensure that our components look the same after they are migrated. This allows sections of our codebase to be migrated by running a script and following up with any necessary tweaks, similar to the approach we took when adopting TypeScript.
The first phase of the migration was handled by the web styling working group and targeted converting a subset of components on a few select pages with varying performance characteristics. This phase was gated on A/B tests which ensured that our initial understanding of the performance held up under the specifics of our app and assured us that there were no hidden problems.
Once we were confident about the performance and correctness of our Linaria integration, we allowed teams to start using Linaria in new code. We also encouraged teams to migrate their existing code using our codemods. Although the migration has proceeded at a good pace organically, we plan to ensure that all code has moved off of react-with-styles so that we can eventually remove the runtime dependencies from the bundles entirely. This consistency will give us an additional performance boost and reduce the cost of decision fatigue.
Contributing Back
Once we started using Linaria, we discovered that automatic style deduplication (i.e. Atomic CSS) would give us not just a performance boost, but also would fix some non-performance-related hiccups we ran into.
The selectors that Linaria generates are all of the same specificity. Since CSS selectors of the same specificity depend on their declaration order, the order that the bundler builds these files becomes important. This is problematic when sharing styles between files, since we cannot predict or maintain the order of the styles as the shape of the dependency graph changes.
We initially approached this problem by creating a new tagged template literal for CSS fragments which allows for the styles to be interpolated into Linaria’s CSS tagged template literals. This works okay, but it is unintuitive, defeats tooling that expects styles to be defined in CSS tagged template literals, and leads to the styles being included several times in the CSS bundles (which is suboptimal for performance).
Josh Nelson, a member of our web styling working group, contributed Atomic CSS support back to Linaria and the Linaria community has been very supportive. The change adds a new @linaria/atomic package that when imported instead of @linaria/core will generate Atomic CSS at build time. This means that if you write your code like this:
Instead of generating output like this (without Atomic CSS):
The generated output will look something like this (with Atomic CSS):
The order of appearance problem is solved by build time analysis that chains class names based on the order they are passed in to the cx function to increase specificity when necessary.
Reception
Our engineers have reacted positively to Linaria. Here are some quotes:
“Linaria opens up a world where we can code like it’s 1999, in old school pure on CSS. It advises against bad patterns, but gives us the flexibility to build amazing experiences. We’re not fighting the platform anymore, we’re harnessing it and it feels incredibly powerful.” — Callie Riggins
“Compared to react-with-styles, I care more about what I’m creating now. Linaria is so good.” — Ian Demattei-Selby
“I really liked being able to write CSS again. It gives you so much more control over what you can style in the component.” — Brie Bunge
“It’s great to be writing actual CSS again.” — Victor Lin
Thanks to its familiar CSS syntax, style extraction into static stylesheets, and application of styles using class names, Linaria increases product development speed and unlocks new styling capabilities not possible with react-with-styles and Aphrodite.
Performance Impact
Though we are still at the beginning of our migration, we have run some A/B tests that give us an encouraging look at the real world performance impact of switching to Linaria for a large group of visitors in the wild.
In one experiment, we converted about 10% of the components rendered on the airbnb.com homepage from react-with-styles to Linaria, and saw Homepage Page Performance Score improve by 0.26%. Time to First Contentful Paint (TTFCP) improved by 0.54% (mean of 790ms), while Total Blocking Time (TBT) also had a strong improvement of 1.6% (mean of 1200ms). To put this in perspective, hydrating the homepage with React takes around 200ms for most people, so improvements of this order of magnitude are significant. We believe these performance improvements with Linaria are attributable to no longer generating CSS styles at render-time, which improves render times on both server and client.
Assuming the performance improvements will scale linearly (which is a big assumption), converting the remaining 90% of the components might result in a 2.6% improvement to Page Performance Score, 5.4% improvement to Time to First Contentful Paint (TTFCP), and 16% improvement to Total Blocking Time (TBT).
Note that direct comparisons with other industry numbers are a little tricky here, given the different ways we define pages especially with regard to client routing.
What Does This Mean for react-with-styles?
Given that we still have many components that still depend on react-with-styles and that it will take a while for us to complete our migration, we will put react-with-styles in maintenance mode until we approach the end of our migration. At that point, we intend to sunset react-with-styles and the related packages.
By removing an option from the marketplace we hope to help the community coalesce towards a common solution and invest in better frameworks. If you are looking for a new tool, we think Linaria is a great choice!
Conclusion
Styling infrastructure is still an exciting space, rich with opportunities. At Airbnb, we’ve found big improvements to the developer experience by adopting a framework that allows regular CSS syntax to be used alongside our React component code. And by replacing a runtime styling library with one that compiles to static CSS files at build time, we are able to continue driving toward faster performance. Thanks to the Linaria community and our collaboration, we expect this library to continue to improve for many years.
Interested in working at Airbnb? Check out these open roles:
Frontend Infrastructure Engineer, Web Platform
Staff Software Engineer, Data Governance
Staff Software Engineer, Cloud Infrastructure
Staff Database Engineer
Staff Software Engineer — ML Ops Platform
Senior/Staff Software Engineer, Service Capabilities
Acknowledgments
We have a lot of appreciation for the folks at callstack and the Linaria community for building such a great tool and for collaborating with us to make it even better. Also for Khan Academy for giving us Aphrodite which served us well for many years. This has been a huge effort at Airbnb that would not have been possible without all the work put in by so many people at Airbnb, including Mars Jullian, Josh Nelson, Nora Tarano, Alan Wright, Jimmy Guo, Ian Demattei-Selby, Victor Lin, Nnenna John, Adrianne Soike, Garrett Berg, Andrew Huth, Austin Wood, Chris Sorenson, and Miles Johnson. Finally, thank you to Surashree Kulkarni for help editing this blog post. Thank you all!
All product names, logos, and brands are property of their respective owners. All company, product and service names used in this website are for identification purposes only. Use of these names, logos, and brands does not imply endorsement.