We just finished ripping twin.macro out of 400+ components in our monorepo and replacing it with vanilla Tailwind. We’re seeing pages that used to compile in dev in multiple seconds now load in <1s and CI builds are down to 2-4 mins from 4-6 mins previously. In addition, in our testing cold starts in the dev environment improved from ~35s to ~1.5s. Since Twin was the last reason we needed to transpile our monorepo using Babel, this migration also unlocked the last piece of our move over to SWC.
This is the story of why we moved away from Twin, our experience migrating to Tailwind, and the DX benefits we now enjoy post migration, like IDE extensions, the Tailwind playground, and toggling classes in browser devtools while debugging styles.
Let’s get into it!
Why did we make this migration?
The short answer is that we wanted to improve performance, reduce dependencies, and provide a better DX for our quickly growing team.
Thanks to Rem Kim for inspiring this blogpost
When we started using Twin around two years ago, there were not a lot of quality-of-life utilities for using Tailwind in React codebases. Libraries like tailwind-merge didn’t exist yet and Twin took care of all that so we liked it.
Twin was also the “best of both worlds” for using styled-components as it made it easy to create style-only components with a list of Tailwind classes. Finally, Twin had Tailwind dynamic value support before Tailwind introduced Just-in-Time mode and Twin circumvented the annoying build tools that Tailwind required before JIT.
Twin worked for us for nearly two years, which is more than we might have expected. If you check out our job postings, you’ll see that one of the things we look for in every engineering role is an awareness of how solutions naturally degrade:
A solid intuition about how long your solutions will last. All systems age. In startups, we can hope for 2-3 orders of magnitude, or 12-18mo
Twin worked well for us … until it didn’t. Our monorepo has grown quickly over the past two years and performance was starting to be a problem during development. Twin doesn't support SWC or any other transpiler written in Rust or Go, so we were stuck with Babel.
We’re not against Babel in general but it was slowing down our builds. If you want my opinion, Babel moved the JS ecosystem forward before browsers and runtimes like Node supported nice things like arrow functions, template strings, promises, and JSX — but now projects like ours are bigger and more complex and faster transpilers written in Go and Rust have made a huge difference in performance.
The Twin maintainer has mentioned that support for SWC would be difficult
Since newer runtimes like SWC now support most of the things we need, we didn’t have a reason to use Babel anymore. Wherever we previously needed it, plugins now exist to fill the gaps.
More on performance gains
Twin and Babel were fast initially. But recently some pages were taking multiple seconds to load if they were not compiled already.
We did some performance tests and found out that Babel + Twin were the biggest issues. Here is an example of the main railway.app landing page loading with a cleared cache before and after the migration.
Before the migration, a cold start took ~35s to load.
After migration, a cold start took ~1.5s!
When we compared an old branch vs master, the difference is 220s vs 42s master.
This test was performed using the following code on a mac computer with the latest M2 processor.
rm -rf .cache && rm -rf .next && yarn nx run @railway/frontend:build --skip-nx-cache
Profiling page loads is more complicated because you have multiple caches and I don't remember which pages where the ones that were taking more time, but in general we’ve seen enormous speedups in the local dev environment.
Ripping out Twin was tricky
The migration required modifying more than 400 components, which we spread out across 30 or so pull requests.
Twin converts classes to CSS objects at compilation time. To mimic the behavior of some Twin functions, we needed to write some of our own utility functions. We wrote a codemod using https://github.com/codemod-js/codemod that helped a lot transitioning to our custom functions and removing the
css attributes to use
As an example, instead of what we had previously:
we now have:
But there were some features that were not possible to translate directly. These required manual intervention. Let’s run through a few of the sticky points.
As a result, we decided to move some of these nested selectors to a CSS file preprocessed by tailwind. It’s not the cleanest solution, but it works.
Twin offers a
resolveConfig but in reading the docs it felt like its usage is discouraged.
Note that this will transitively pull in a lot of our build-time dependencies, resulting in bigger client-side bundle size. To avoid this, we recommend using a tool like babel-plugin-preval to generate a static version of your configuration at build-time
For these cases we modified the code to use CSS variables wherever possible. This was a pretty manual process, but it was the surest way we could guarantee compatibility.
To solve the precedence problem, we first modified pages and layouts, then high-level components, then core components
During the progressive migration, we learned that Twin styles were taking precedence over Tailwind styles because styled component styles are inclined and Tailwind styles and linked as a style sheet. This was an issue if a core component was created and styled with Twin but another component using it was trying to add or change those styles using vanilla Tailwind.
Another big issue was that Twin automatically merges styles. For example, let’s say you have a core component with a specific padding. If you use it somewhere else and pass it another padding, the latter takes precedence. Twin takes care of that at build time.
With vanilla Tailwind we just have a list of classes and their precedence is calculated by the browser without any "merging" calculation. A CSS class takes precedence over another one if the latter one is declared before the former one.
To solve the precedence problem, we broke the migration up into around 30 smaller PRs. This let us reason about the changes better and avoid breaking things. We started with pages and layouts, then high-level components, and finally core components.
High-level components were used in just a few places so we could double-check if they were being rendered correctly. Sometimes we had to use the
! in some classes if these components were using core components and overwriting their styles. We also used tailwind-merge which is a handy little utility function to merge without conflicts.
We quickly patched precedence issues as they cropped up
Since we migrated core components last, all the higher level components using it were already migrated, so both types of components were using vanilla Tailwind and precedence wasn’t a problem anymore.
Overall, we’re happy with the migration to vanilla Tailwind. We had some issues in prod during the migration but we were able to fix them quickly. We benefited greatly from an approach that solved the precedence problem by working with larger components (like pages and layouts) first, all the way down to smaller core components.
As a result of the migration, we’re enjoyed a dramatic speedup in local dev build times. We’ve also been able to take advantage of Tailwind’s IDE extensions like Tailwind CSS IntelliSense. We’ve found the Tailwind playground useful for prototyping new ideas on the fly and we also like that we can now debug styles in the browser with devtools.
Finally, we can also use new Tailwind features as soon as they are available (rather than having to wait for Twin support) which is great since Tailwind is evolving quickly.
If we had to do it over again, we’d probably set expectations a little better before the migration — it was a lot of effort to migrate 400+ components and ended up taking longer than we thought to chase down every last corner case.