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:
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:
script.js
, which would house all the rewritten logic previously in React,
stylesheet.css
,
and
test.html
, which just imported the former two:
<div id="getWaitlistContainer" data-waitlist_id="4594"></div>
<link rel="stylesheet" type="text/css" href="stylesheet.css" />
<script src="script.js"></script>
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.
Then it was time to actually transpose all the React code to Vanilla JS. This was fine. JavaScript and the browser API has come a long way
since the jQuery days, and I generally found the
fetch
and
document
APIs easy to use.
Instead of React-style rendering logic, I stuck to to simple event handlers that would set
innerHTML
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
<a>
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
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.