Skip to content

How I cut 91 KB of JavaScript from my Base44 site without changing a pixel

The JavaScript my Base44 site loads before first paint went from 336 KB to 245 KB gzip, and phone scrolling went from 54 janky frames to none. No speed change could merge until screenshots of every route matched the old build. What worked, what I threw away, and why Lighthouse disagrees.

Aatman (AJ) Jain9 min readUpdated 28 Sept 2026
Scissors with orange handles snip a long, curling receipt of code lines on lime paper, and the trimmed strip curls away to one side.
Scissors with orange handles snip a long, curling receipt of code lines on lime paper, and the trimmed strip curls away to one side.

I cut the JavaScript my Base44 site loads before its first paint from 336 KB to 245 KB gzipped (91 KB less), mostly by loading the Base44 SDK after the first paint and lazy-loading menus and popovers. Nothing merged until screenshots of every route matched the old build or the difference was proven noise, and phone scrolling went from 54 janky frames to none.

Here's how, including what I threw away and the one number that got worse. It's all been live since 28 September.

less JavaScript before first paint
91 KB
frames over 200 ms in a phone scroll
54 → 0
main thread busy per 5 idle seconds on my home page, down from 2,317
49 ms

Why my site needed it

This isn't a light site. Smooth scrolling, framer-motion entrances, torn-paper edges, doodles that draw themselves. I love all of it. Phones did not.

On a phone profile (390 px wide, CPU slowed 4x, fast 3G), a scripted touch scroll down my home page had 54 frames that took longer than 200 ms each. The worst took 749 ms. And sitting perfectly still on the home page, doing absolutely nothing, the main thread was busy for 2,317 ms out of every 5 seconds.

Busy doing nothing. That's the bit that annoyed me.

The rule: measure your noise first

The rule that made this whole thing work: compare the old build with itself. Same commit, two runs, a screenshot of every route, then a diff.

They weren't identical. Drifting clouds, doodles caught mid-draw, a carousel on a different slide. That's noise. If you don't know what your noise looks like, every diff looks like a bug. Worse, a real bug starts to look like noise.

To show you what I mean, I rebuilt both versions locally for this post and screenshotted /about the same way twice:

Before against after: 0 pixels. The same build against itself: 1,634. That's noise.

So every change was measured against one base commit, and every difference had to be explained. Either it also showed up when I compared the base with itself, or it was meant to happen. Anything else didn't ship.

The gates were strict:

  • Full-page screenshots of every route at 1440 and 390 px with motion reduced, plus runs with motion on.
  • SEO parity on all 72 routes: titles, meta tags, canonicals and structured data.
  • Animation checks: the same elements had to animate and land on exactly the same end values.
  • Focus rings: the final run compared 15,910 elements and found 0 differences.

I split the work into six packages, each on its own branch and each handled by its own Claude Code agent. The sixth merged the other five, added its own changes and re-measured the lot.

Each package measured alone against the base, then merged and measured again

What moved the numbers

The CSS selector that restyled 4,676 elements

The sneakiest one. Lenis, the smooth-scroll library, rewrites the classes on <html> several times per scroll gesture. That should be cheap. But two of my focus-ring rules tested the class attribute:

css
/* Before: [class] in the selector, so every class change on <html>
   made the browser restyle the whole document. */
[data-nav-tone='dark'] :focus-visible,
[data-nav-tone='dark'] [class]:focus-visible { outline-color: var(--lime); }

/* After: same specificity (0,3,0) from a doubled attribute, no class-attribute test. */
[data-nav-tone='dark'][data-nav-tone='dark'] :focus-visible { outline-color: var(--lime); }

Before, one Lenis class change restyled 4,676 elements. After, 0. On its own, that didn't stop the full-page restyles while scrolling. That took this plus a patch that makes Lenis only add or remove the classes that actually changed. Together, style time during a desktop wheel scroll fell from 1.14 s to 0.10 s.

The same package split Tailwind into a public sheet and an admin sheet, so visitors stop downloading styles for pages they'll never see.

The Base44 SDK waits until after first paint

The SDK and its network libraries were in the entry bundle, so the first paint waited for them. Now they load the first time something asks for data:

js
let pending = null;

export function loadBase44Module() {
  if (!pending) {
    pending = import('./base44Client').catch((error) => {
      pending = null; // a failed download isn't remembered, so the next call tries again
      throw error;
    });
  }
  return pending;
}

export const loadBase44 = () => loadBase44Module().then((m) => m.base44);

Every query and form now awaits loadBase44(). That took about 40 KB gzip off every page's critical path. It wasn't free. In that package's own test, /work/hattle's blocking time went up, because the SDK now starts after the paint instead of before it. By the final build it was below where it started: 954 to 805 ms.

That package also put analytics behind cookie consent, mounted the toast component once the page is idle, started downloading a page's code when you hover or focus its link, and cut the brand icon file down to the 58 icons I actually use.

The mobile menu, the desktop mega menu and the blog's contents popover now live in their own chunks, fetched when the browser is idle or when you reach for them. The home page also stopped importing full content files just to show a few projects, and reads small index files instead. That package took about 39 KB gzip off the JavaScript before first paint.

Letting the page sit still

This package patches Lenis and framer-motion at build time. Lenis only touches the classes that changed, its touch listeners are passive, and its animation loop parks 5 seconds after your last input (when nothing is moving) and wakes on the next one. Framer stops watching an element once its one-time entrance has played.

Each patch finds an exact piece of text in the library's file and replaces it. If an upgrade moves that text, the build warns and skips the patch rather than failing. (It's the same trick behind the framer fixes that stopped my Base44 site going blank.)

The parking patch nearly failed its own test. The version in the original plan jumped by up to 247 px after a slow frame in a unit test. Using the median of the last 5 frame intervals kept it within 0.82 px of stock Lenis, so that's what shipped.

Wiring it all together

The final package grouped the code the first render needs into fewer files. It serves Google's own font files from my domain, but only to browsers Google sends identical files to (I checked 60 user agents). Every other browser still gets Google's stylesheet, exactly as before. And the hero images start downloading while the HTML is still being read. Bots get no preloads.

Here's what that looks like on one page. I recorded every request before first paint on /about, on both builds, same throttled phone:

Twenty files in the first wave became six, and the SDK left the queue

That's one local run of each for this post, not the medians below, but the shape is the point. Fewer files up front, and the page's own code gets going sooner.

What I threw away

This is my favourite list, and a couple of them hurt. Every item on both sides was measured, not guessed:

Thrown away

  • Responsive home polaroids: 0.976 to 0.986 on a 0.995 visual similarity gate
  • The logo marquee on the Web Animations API: edge pixels off by up to 42 of 255
  • Mounting the home page in stages: a fast flick landed on an empty placeholder
  • Terser: it saved 0.6 KB gzip
  • Preloading route chunks: failed my largest-paint check on 4G

Kept, with a known cost

  • The lazy SDK: /work/hattle's blocking time rose in its own test, and ended at 954 to 805 ms
  • Lazy menus: one tapped in the first seconds on slow 3G opens about 1.4 s later
  • The Lenis class patch: anti-aliasing differences of 13 out of 255 at most
  • The home polaroid preload: desktop largest paint 440 ms faster on 4G, first paint 130 ms slower

The marquee one stung. With it on screen, a throttled phone's main thread was about 90% busy, so the prize was huge. But the pixels didn't match, and pixels were the deal.

The pixel I argued about

I'll be straight with you about the title. The Lenis class patch causes tiny anti-aliasing differences, 13 out of 255 at most, on a few edges: rotated shadows, card corners, one underline. The DOM, computed styles and layer tree are identical. And the old build compared with itself, scrolled a different way, shows the same kind of difference.

So I kept it, because removing it also loses the restyle win. The final screenshot run: 127 of 144 were identical apart from one new footer link. The other 17 were either pages whose copy was meant to change (the cookie banner work touched the privacy and accessibility pages, plus the calendar on /book) or noise that shows up base against base.

The results

Paint times on a throttled phone, before and after. The method is under the chart.

First and largest paint on a throttled phone

BeforeAfterLower is better
/ first paint4,052 ms3,608 ms/about first paint3,348 ms2,724 ms/services first paint3,304 ms2,804 ms/work/hattle first paint3,320 ms3,312 ms/work/hattle largest paint6,648 ms5,520 ms
First and largest paint on a throttled phone (ms), lower is better
RowBeforeAfterChange
/ first paint4,052 ms3,608 ms11% lower
/about first paint3,348 ms2,724 ms19% lower
/services first paint3,304 ms2,804 ms15% lower
/work/hattle first paint3,320 ms3,312 ms0.2% lower
/work/hattle largest paint6,648 ms5,520 ms17% lower

Medians of interleaved runs, base commit 69fbaed against everything merged, over a local HTTP/2 server set up like an edge. 390 px, 4x CPU slowdown, fast 3G.

Not everything moved. /work/hattle's first paint barely changed (3,320 to 3,312 ms), even though its largest paint dropped by more than a second.

The bytes are where the 91 KB shows up:

Bytes before first paint, every route

BeforeAfterLower is better
JavaScript, gzip336 KB245 KBJavaScript, brotli292 KB213 KBCSS, brotli33.9 KB27 KB
Bytes before first paint, every route (KB), lower is better
RowBeforeAfterChange
JavaScript, gzip336 KB245 KB27% lower
JavaScript, brotli292 KB213 KB27% lower
CSS, brotli33.9 KB27 KB20% lower

Compressed size of everything requested before first paint, same builds and server as above.

And the rest of the numbers:

MeasureBeforeAfter
Requests before first paint5447
Touch scroll of /: frames over 200 ms540
Touch scroll of /: worst frame749 ms88 ms
Touch scroll of /: style time20.6 s1.5 s
Touch scroll of /: main-thread blocking17.5 s28 ms
Idle main thread per 5 s on /2,317 ms49 ms

Lighthouse says it got worse

My local Lighthouse runs show simulated mobile first paint going from 4.2 s to 5.6 s, and largest paint on the home page from 5.7 s to 7.2 s. Mobile scores: home 49 to 49, /work/hattle 49 to 55.

Why? Lighthouse's simulation counts fonts as render-blocking when they finish before the paint it observed. Fonts from my own domain now finish in time, so they count. Fonts from Google's servers often didn't, so they didn't. The real throttled paint in the charts is faster.

Do this on your own Base44 app

  1. Pin a base

    Pick one base commit and measure everything against it.

  2. Measure your noise

    Diff the base against itself before you diff anything else.

  3. Throttle like a phone

    390 px, 4x CPU, fast 3G. Take medians of interleaved runs.

  4. Hunt class selectors

    If you use a smooth-scroll library, search your CSS for [class] and [class*= selectors.

  5. Defer the SDK

    If your first paint doesn't need data, load the Base44 SDK on first use.

  6. Lazy-load taps

    Menus, popovers and dialogs only appear after a tap, so load them then.

  7. Keep a dropped list

    Write down what you threw away and why.

  8. Check the crawler view

    After you deploy, run the two-curl test.

If you want a second pair of eyes on a slow app, that's what my SEO and performance work is for.

FAQ

How do I make my Base44 app load faster?

Start by measuring what loads before the first paint on a throttled phone profile. On my site the biggest JavaScript wins came from loading the Base44 SDK after first paint and lazy-loading menus and popovers, then grouping first-render code into fewer files. Together that took JavaScript before first paint from 336 KB to 245 KB gzip.

Will lazy loading hurt my Base44 SEO?

Base44 captures its crawler snapshots after your JavaScript runs, so content from lazy-loaded code can still make it in. My SEO parity check found titles, meta tags, canonicals and structured data unchanged on 66 of 72 routes, and the other 6 were expected changes. Still, check the live crawler view after you deploy, because a snapshot can catch a loading screen.

Why did my Lighthouse score get worse after self-hosting fonts?

Lighthouse's mobile simulation counts fonts as render-blocking when they finish before the paint it observed, and same-origin fonts usually do. My simulated first paint got slower while real throttled paint got faster. Compare against a real throttled run before you undo anything.

Do I have to leave Base44 to do this kind of work?

No. Everything here lives in the app's GitHub repo, which syncs to Base44 when changes land on the main branch. The build-time patches are Vite plugins in the same vite.config.js that Base44 builds with.

Keep reading

More posts on the same things.

Newsletter

Liked this one?

Get my Friday email: new posts, what I built and the Base44 and AI news worth knowing.

No spam. Unsubscribe whenever.

Got an app in mind?

I build and fix Base44 apps, and I'm looking for a remote internship. Tell me what you're working on.