← All posts

Simplifying My Stack to Speed Up My Site

Quick optimizations on stuffiminto.com

5 min read

I noticed one of the personal projects I was working on recently, stuffiminto.com, seemed to be a little slow when I navigated from page to page. The old site was built on React Router 7, NestJS, and Supabase. The following is a quick list of what I did to improve performance and simplify the codebase.

Before I touched any code, I ran an experiment by adding logs to see how long it took for my profile page to load, both when logged in and logged out:

Logged out · 121 ms of loaders + render (average of 3 runs)

No cookie, so no token refresh and no guard lookups. Every Prisma statement was still a ~10 ms round trip to Supabase. Loader totals in the three runs: 100, 102 and 142 ms.

0 ms 50 100 150 10 ms per line, one Supabase round trip PAGE LOADERGET /users/chris 4 statements · avg of 3 GET /users/chris: 6.9 ms — Loopback hop to Nest. GET /users/chris: 38.7 ms — One user.findUnique with three includes, each its own sequential statement: four round trips at about 10 ms. Runs: 41.2, 37.1, 37.9 ms. GET /users/chris: 2.9 ms — Nest pipeline and JSON encoding. 48.5 ms GET /tabs/:id/categories 2 statements · avg of 3 GET /tabs/:id/categories: 11.8 ms — Loopback hop to Nest. GET /tabs/:id/categories: 22.4 ms — Tab check, then categories: two sequential round trips. Runs: 19.5, 21.3, 26.5 ms. GET /tabs/:id/categories: 3.6 ms — Nest pipeline and JSON encoding. 37.8 ms GET /tabs/:id/reviews 5 statements · avg of 3 GET /tabs/:id/reviews: 15.0 ms — Loopback hop to Nest. GET /tabs/:id/reviews: 47.3 ms — Tab check, count and findMany in parallel, then review-category links and categories. Runs: 45.3, 44.0, 52.7 ms. 62.3 ms RENDERReact server render onAllReady · avg of 3 React server render: 7.8 ms — renderToPipeableStream until the shell is ready. 9.1, 7.4 and 7.0 ms across the runs. 7.8 ms

Logged in · 148 ms of API calls + render (one representative run)

React Router called a NestJS API on the same droplet, and Prisma talked to Supabase Postgres about 10 ms away. Not shown: before any of this, each loader spent 77 to 92 ms refreshing the Supabase access token over the network.

0 ms 50 100 150 10 ms per line, one Supabase round trip ROOT LOADERGET /auth/me 1 statement GET /auth/me: 7.8 ms — Loopback hop: Node → nginx → Nest and the response back. GET /auth/me: 10.3 ms — Auth guard: verify the JWT locally, then load the user row from Supabase. One round trip on every authenticated call. GET /auth/me: 4.0 ms — Nest pipeline: validation pipe, serializer interceptor, JSON encoding. 22.1 ms PAGE LOADERGET /users/chris guard + 5 statements GET /users/chris: 5.1 ms — Loopback hop to Nest. GET /users/chris: 10.4 ms — Auth guard user lookup: one round trip to Supabase. GET /users/chris: 47.0 ms — One user.findUnique with four includes. Prisma ran each include as its own statement, one after another: five round trips at about 10 ms each. GET /users/chris: 4.0 ms — Nest pipeline and JSON encoding. 66.5 ms GET /tabs/:id/categories 2 statements GET /tabs/:id/categories: 6.3 ms — Loopback hop to Nest. GET /tabs/:id/categories: 21.4 ms — Check the tab exists, then list its categories: two sequential round trips. Ran in parallel with the reviews call. GET /tabs/:id/categories: 1.5 ms — Nest pipeline and JSON encoding. 29.2 ms GET /tabs/:id/reviews guard + 6 statements GET /tabs/:id/reviews: 6.4 ms — Loopback hop to Nest. GET /tabs/:id/reviews: 10.5 ms — Auth guard user lookup: one round trip to Supabase. GET /tabs/:id/reviews: 53.8 ms — Tab check, then count and findMany in parallel, then review-category links, categories, and the viewer's bookmarks: about five sequential hops. 70.7 ms RENDERReact server render onAllReady React server render: 8.5 ms — renderToPipeableStream until the shell is ready, after the slower loader finished. 8.5 ms
Session / authDatabase (Supabase)API hop (Nest)Render, other
Timings from logs in the React Router loaders and the Nest API on the old droplet. The root and page loaders run in parallel; hover a bar for detail.

Here’s what I did to improve my app.

1 - Simplify by removing the NestJS API

An extra folder for an API was just not necessary for such a small app. Instead of using both React Router 7 and NestJS, I migrated to using just React Router 7, which would handle the authentication and DB calls in its own server.

This would have a very minor speed benefit (since the frontend doesn’t need to send and wait for an API call to a separate backend), but the main benefit is that I have less code to maintain.

2 - Migrate off Supabase

I was on the free plan of Supabase, meaning that I had limits on my DB size (500 MB) and my project would be paused automatically after 1 week of inactivity. Those are just unnecessary constraints that I didn’t want to deal with; if I stopped using my project for a week or two it would stop working. On top of this, I have to be tied into an additional software and company, who could end my plan or decide that I need to start paying for my project instead of relying on the free tier.

So I decided to opt for a much simpler solution - SQLite. This introduces fewer dependencies to my project, and will also be a lot faster than Supabase, because it is located locally, right next to the API.

I also migrated to Better Auth for authentication. This had an edge case that minorly improved performance. Supabase’s access tokens expired every hour. If a request came in with a JWT that was near its expiration date, my React Router loaders would refresh it, which involved a round trip call to Supabase that took ~80 ms. And because the root loader and the page loader each built their own Supabase client, this happened twice per page. With Better Auth there is no short-lived access token at all: the cookie holds a session token, the session is a row in my own database, and checking it is a local lookup. Nothing ever needs refreshing, and there’s no extra trip to a third party API.

3 - Self hosted the Lato font

Quick simple win. I was using Google Fonts before this, meaning that the client had to fetch a stylesheet from fonts.googleapis.com and then the font files from fonts.gstatic.com. The stylesheet is render-blocking, so nothing painted until it arrived, and each of those two domains required its own DNS lookup and TLS handshake whenever the data was not already cached. Self hosting the font gets rid of both extra hops, since the files now come over the same connection as the page itself.

4 - Combined data fetching into 1 API call

Previously, on the user profile page my loader fetched the following:

  • User information (bio, tabs, date joined, etc.)
  • Category information
  • Reviews for the current tab

The user request had to finish first, then the categories and reviews were fetched in parallel, so the loader waited on two round trips in a row.

While this made sense from an API perspective (keeping each piece of info a distinct route), it meant a couple of sequential API calls in my loader. I could either have combined these into 1 call, or used a Promise.all to fire them off at the same time. I elected for making 1 API call to simplify things, to a /page endpoint.

However, I further simplified the app by just using RR7 and getting rid of the Nest API, so that made things even easier, since I could call the database directly.

5 - Lazy load client side JS

This is a very minor optimization that probably didn’t improve loading speed at all, but was good practice nonetheless. The majority of the time, the user viewing the profile will simply want to view the content, and NOT edit the content itself, but the JavaScript for editing was shipped every time. Instead, we can lazy load this code so it is only downloaded when the viewer is the profile’s owner. I did the same for the login and signup modals, which now load the first time a visitor clicks “Log in” or “Sign up” (and start fetching on hover, so there’s no visible wait). This is a small amount of JS (about 10 KB gzipped) but it’s still good digital hygiene to follow.

Results

Overall, these optimizations saved about 120 to 200 ms when measuring time to first byte from the server, and another 100 ms between first byte and first paint (due to self hosting the font).

And I got to get rid of a bunch of overly-complex dependencies (Supabase and NestJS), which is always a big win!