⚛️ Rewriting our core React App in Vanilla JS
By Maya Kyler
on December 12, 2022
Why we started with a React Monolith
When we started building Waitlist two years ago, we built the entire frontend in React using NextJS, deployed via Vercel. Waitlist is an indie SAAS business: we offer highly customizable Waitlist Widgets that people can put on their websites to collect signups before they launch their next big thing. People who sign up can refer their friends to move up in line, and we also include built-in email marketing, custom Q&A, zapier integrations, and many more neat features.
Our core product is our Waitlist Widget. (Example Demo
.) On day one, we wrote it as part of the React app that powers our website and all our user-facing dashboards that let you configure your Waitlist. We did that so we could focus on one set of APIs, enforce uniform design, and easily integrate the Widget into the dashboards. Frankly, we thought it would be easier to start with a monolith rather than overthinking architecture for a project where we didn't even fully know what the end result would look like.
Thus, for the longest time, the Waitlist Widget was just another page on our React app, and users could integrate it into their websites using an iframe. We also offer a Waitlist API for users to integrate, but the Widget was used most. To-date, we've powered over 2.5 million signups using our iframe Widget.
Why we went for a Vanilla JS rewrite
The iframe approach had a couple of problems:
- It's a monolithic React App that powers lots of other things beyond the Waitlist Widget, so even with progressive loading and chunking, that often meant a ~700-900kB pre-render download across many different queries if it wasn't already cached, and a total time to render of about 1.5 seconds.
- While iframes are universal and easy to integrate, they don't resize well dynamically, and some iframe behavior isn't uniform across browsers. For example, on OS X, using
navigator.clipboard.writeText to copy text to the clipboard doesn't work from inside iframes in Chrome, though it works in Firefox.
- A lot of our existing React code from two years ago wasn't very good. Forgiving programming environments like React often encourage bad software engineering patterns by not explicitly discouraging them.
- The fact that the Widget was implemented as a page on a React app made it somewhat cumbersome to pass in outside state or parameters, let alone updating its configuration without an API call. I wanted the parameters of the Widget to be more exposed for modification on the fly, without needing to POST to save and GET to re-render the Widget page.
I decided that everything about the Widget should be light and fast. That meant no complicated build chain, fast deploys, no React server, just minified JS and CSS files that are as small as possible, served via a CDN so we can provide the highest-performance experience to our users. And I wanted all the logic to be as simple and predictable as possible — I had been put off by the cross-browser surprises with the iframe experience. No polyfills, no compiled JS that I couldn't reason about.
The Rewrite to Vanilla JS
I made three files:
, which would house all the rewritten logic previously in React,
, which just imported the former two:
<div id="getWaitlistContainer" data-waitlist_id="4594"></div>
<link rel="stylesheet" type="text/css" href="stylesheet.css" />
First things first, I had to set up a build chain. I wrote a simple python script that would do four things:
- Run UglifyJS on my JS file;
- Run Clean-CSS on my CSS file;
- Upload both to S3 on AWS, which would be the CDN serving the minified JS and CSS, which users could then integrate and make use of by copying the three lines of code I showed you just above.
- Report that none of these steps resulted in any errors.
APIs easy to use. Instead of React-style rendering logic, I stuck to to simple event handlers that would set
on the right elements when the right actions were taken. TailwindUI
made it easy to configure the visual effects and elements correctly. All of this was pretty elegant, easy to stay on top of since it's not thousands
of lines of code, and it felt good to write code that was tight and right on point, rather than bloated with lots of layers of abstraction. I completed the rewrite with around 66% fewer lines of code in VanillaJS than in React.
A VanillaJS rewrite also implies Vanilla HTML components. One of my least favorite patterns in modern frontend development is importing a custom "Select" or "Button" component, and then finding out that under the hood it's like fifteen nested divs with a gazillion CSS rules and the operative element at the end is an out-of-context abused
tag. I just wanted to use the good-old HTML input, button, select, etc. elements, which would also guarantee to me that they would show up and be well-supported in all browser environments. Surprisingly, this was really tedious
in its subtleties:
- Placeholders for input and select fields cannot be directly configured, but need several CSS styles to be applied to them.
- When a user uses the "browser autofill" feature on an input field, the browser dictates the background color of the input field, unless you set a totally bewildering CSS workaround.
- Basic styling, like the height of a select element, cannot be applied without similarly extensive workarounds.
For me, this really emphasized a need in more flexible configuration for HTML elements going forward. We shouldn't have to write all these crazy workarounds in JS and CSS just to achieve basic visual modifications on these core elements.
I ran into a couple other gotchas along the way that were just bonkers. Did you know that when you upload a CSS file to AWS, its content-type gets interpreted as
octet-stream by default (instead of the much more sensible
text/css), which means the browser can download and read it, but not interpret it as a stylesheet? That single obscure fact burned a solid hour of debugging time. I also had to stay mindful of global variables — because my JS script simply runs on the page, its variables, functions, etc. are in global scope. The same applies for my CSS styles. This meant I had to carefully prefix everything I declared with
getwaitlist so it wouldn't collide with the user's local variables or styles. (This made me appreciate my regular React build chain more, which does this sort of thing automatically.)
Finally, I use Sentry
for monitoring and error capture for all other aspects of Waitlist, but the Sentry widget was a real pain to install on a VanillaJS script. I ended up just downloading the most recent Sentry Widget — after all, it's just a bunch of JS — and inlined it in my Widget codebase, and then wrapped all my core functions with it. I inlined as many dependencies as I could for this project: for example, instead of loading any images from other sources, I inlined all of my media assets as SVGs in code, and partially minified those SVGs by hand!
The result is wonderful: when a user accesses a website that loads the Waitlist Widget uncached, it's only two HTTP requests — one JS and one CSS file, both minified and served over S3. A total load of barely 160kB, with minimal latency. Everything renders in under 500 ms. Much faster, much lighter, with a minimal integration to the Waitlist API. Easy to maintain, easy to monitor, easy to deploy. Exactly the kind of set-it-and-forget-it peace of mind that you want to have when you're an indie dev like me.