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:
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.
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:
/* 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:
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.
Menus that load when you reach for them
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:
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
| Row | Before | After | Change |
|---|---|---|---|
| / first paint | 4,052 ms | 3,608 ms | 11% lower |
| /about first paint | 3,348 ms | 2,724 ms | 19% lower |
| /services first paint | 3,304 ms | 2,804 ms | 15% lower |
| /work/hattle first paint | 3,320 ms | 3,312 ms | 0.2% lower |
| /work/hattle largest paint | 6,648 ms | 5,520 ms | 17% 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
| Row | Before | After | Change |
|---|---|---|---|
| JavaScript, gzip | 336 KB | 245 KB | 27% lower |
| JavaScript, brotli | 292 KB | 213 KB | 27% lower |
| CSS, brotli | 33.9 KB | 27 KB | 20% lower |
Compressed size of everything requested before first paint, same builds and server as above.
And the rest of the numbers:
| Measure | Before | After |
|---|---|---|
| Requests before first paint | 54 | 47 |
| Touch scroll of /: frames over 200 ms | 54 | 0 |
| Touch scroll of /: worst frame | 749 ms | 88 ms |
| Touch scroll of /: style time | 20.6 s | 1.5 s |
| Touch scroll of /: main-thread blocking | 17.5 s | 28 ms |
| Idle main thread per 5 s on / | 2,317 ms | 49 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
Pin a base
Pick one base commit and measure everything against it.
Measure your noise
Diff the base against itself before you diff anything else.
Throttle like a phone
390 px, 4x CPU, fast 3G. Take medians of interleaved runs.
Hunt class selectors
If you use a smooth-scroll library, search your CSS for
[class]and[class*=selectors.Defer the SDK
If your first paint doesn't need data, load the Base44 SDK on first use.
Lazy-load taps
Menus, popovers and dialogs only appear after a tap, so load them then.
Keep a dropped list
Write down what you threw away and why.
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.




