From 76 to 91: How We Cut 10 MB of Images and a 113 KB JS Bundle from a Kids App Landing Page
A few weeks ago I wrote about building the kidsgamesapp.com marketing site with Astro and GSAP. I was pretty happy with it. Smooth animations, floating shapes, staggered scroll reveals. It looked great.
Then I ran Lighthouse.
76 Performance. 5.1-second LCP. Accessibility 89, Best Practices 96, SEO 90.
For context, this is a static Astro site with no client-side routing, no heavy framework, no real-time data. A 5.1s LCP on a static site is embarrassing. Something was very wrong.
Three things, actually. And fixing all three took us to 91 / 100 / 100 / 100.
What Lighthouse Actually Found
The Lighthouse report broke the problem into three buckets:
Serve images in next-gen formats — potential savings of ~8 MB. This was the big one. Lighthouse had found images being sent as raw, uncompressed PNGs when the browser could have received AVIF or WebP at a fraction of the size.
Serve static assets with an efficient cache policy — Lighthouse flagged several assets as having a short or incorrectly configured max-age. The nginx config was wrong in a subtle way I’ll explain below.
Eliminate render-blocking resources — the GSAP + ScrollTrigger bundle, ~113 KB transferred, loading synchronously before the page could paint.
One thing worth noting about Lighthouse numbers: they come from a simulated mid-tier mobile device on a throttled 4G connection. The “potential savings” figures are what Lighthouse estimates you’d save in that simulation — not what a real user on a fast connection would experience. Real-world improvement shows up in Chrome’s CrUX data with a ~28-day lag. But the simulation is still an accurate proxy for what slow-network users experience, and 5.1s LCP is slow on any network.
So: images, caching, JS bundle. Let’s go through them one at a time.
Fix 1 — Astro <Picture> for Local Screenshots (−8 MB)
My first instinct was wrong. I assumed the images being flagged were the theme carousel thumbnails in the hero section — four game artwork images loaded from our CDN at images.kidsgamesapp.com. Those images were already served as WebP from CloudFront, so they weren’t the culprit.
The actual offenders were four app screenshot PNGs sitting in public/assets/screenshots/. They were 2 MB each. Raw PNG. No compression, no resizing, no format negotiation. Just big files handed directly to the browser as-is.
The problem was where they lived. Astro’s build-time image optimization pipeline only works on images it can see at build time — files in src/assets/ or remote images from allowlisted domains. Files in public/ bypass the pipeline entirely and get copied to the output directory unchanged.
The fix was to move the screenshots out of public/ and into src/assets/screenshots/, then replace the raw <img> tags with Astro’s <Picture> component:
import { Picture } from 'astro:assets';
import screenshot1 from '../assets/screenshots/screenshot-1.png';
<Picture
src={screenshot1}
alt="Puzzle game screenshot"
widths={[400, 800, 1200]}
sizes="(max-width: 768px) 100vw, 50vw"
formats={['avif', 'webp']}
/>
Astro’s Sharp-based pipeline takes over from there: it resizes to each requested width, encodes as AVIF and WebP, and emits a <picture> element with <source> sets pointing at the optimized versions. The browser picks the best format it supports and the closest size to what it actually needs. Four 2 MB PNGs became a handful of 40–80 KB AVIFs.
The remote image gotcha. The hero carousel thumbnails are remote — they live on images.kidsgamesapp.com, not in the repo. Astro’s image pipeline can optimize remote images too, but you have to opt in explicitly in astro.config.mjs:
export default defineConfig({
image: {
domains: ['images.kidsgamesapp.com'],
},
});
And remote images need explicit width and height props, because Astro can’t probe the dimensions of a remote URL without fetching it at build time:
<Picture
src="https://images.kidsgamesapp.com/themes/frozen.png"
alt="Ice Kingdom"
width={330}
height={558}
widths={[110, 220, 330]}
sizes="(max-width: 640px) 25vw, 110px"
formats={['avif', 'webp']}
/>
The width/height you pass determines the aspect ratio for the output images — get it wrong and you’ll get squashed or stretched art. Our theme images are 604×1024 source, displayed at a maximum of 110 CSS px wide, so 330px at 3× DPR is the right ceiling.
Fix 2 — nginx Cache-Control: the immutable Footgun
The nginx config before the fix looked like this:
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
There are two problems here.
The immutable directive tells browsers: “This file will never change. Do not revalidate it, ever, even if the cache expires.” That’s a valid optimization — but only for files with content hashes in their filenames. Astro’s build pipeline fingerprints the files it outputs to /_astro/ (things like /_astro/main.Bx7K9mLP.css). If the CSS changes, the hash changes, the URL changes, and the browser fetches the new file. immutable is safe there.
But fonts, icons, and screenshot images under /fonts/ and /assets/ have stable filenames. If we push a new version of icon.png, browsers that already cached it with immutable will keep serving the old one forever — they’ve been told it can never change, so they’ll never ask for an update. This is a silent, permanent bug that only affects returning visitors.
The 7-day max-age is also too short for /_astro/ content. Those files are content-hashed. When the content changes, the URL changes. The old URL is functionally dead the moment a new deploy goes out. Setting them to 7 days means users re-download perfectly cacheable JS and CSS on day 8 for no reason.
The fix is two separate location blocks:
# Astro build output — content-hashed, safe to cache forever
location /_astro/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Everything else — stable filenames, may be overwritten on deploy
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|avif|webp)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
Note that avif and webp are in the second block too — since we’re now serving those formats, they need to be included in the mime type list, and they should definitely not have immutable.
Fix 3 — Deleting GSAP (−113 KB)
This one’s a bit ironic given that the original landing page post has “GSAP” in the title.
GSAP is a great library. It has a fantastic API, rock-solid cross-browser behavior, and a ScrollTrigger plugin that makes scroll-driven animations genuinely easy to build. I used it for three things on this page:
- A staggered hero entrance sequence (badge fades in, headline rises, subtitle follows, CTA pops in)
- Floating background shapes that drift up and down in slow loops
- Scroll-in reveal animations for each section’s cards as they enter the viewport
The problem is that GSAP + ScrollTrigger is ~113 KB transferred. That’s 113 KB that has to parse and execute before the page is interactive. For a marketing landing page targeting parents on mobile networks, that’s a real cost.
Here’s the thing: everything GSAP was doing here is expressible in CSS. The staggered entrance is just animation-delay. The floating shapes are just animation: drift infinite alternate. The scroll reveals are transition triggered by a class added via IntersectionObserver. GSAP’s JavaScript is handling timing that CSS handles natively.
So we deleted GSAP and replaced it with this in global.css:
@keyframes hero-rise {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes hero-pop {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
#hero-badge { animation: hero-rise 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; }
#hero-headline { animation: hero-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.3s both; }
#hero-sub { animation: hero-rise 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.6s both; }
#hero-cta { animation: hero-pop 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.9s both; }
The animation-fill-mode: both keeps elements invisible before their animation starts (via the from keyframe), which is what GSAP’s autoAlpha: 0 was doing. The cubic-bezier(0.16, 1, 0.3, 1) is an “ease out expo” curve — the same feel as GSAP’s default.
For the floating background shapes, we need per-element variance in direction, speed, and start delay. GSAP was setting these per-element in JavaScript. With CSS, we do the same thing using CSS custom properties:
@keyframes bg-shape-drift {
from { transform: translate(0, 0); }
to { transform: translate(var(--bg-xr, 10px), var(--bg-yr, -10px)); }
}
.bg-shape {
animation: bg-shape-drift var(--bg-dur, 6s) ease-in-out infinite alternate;
animation-delay: var(--bg-delay, 0s);
}
And a 20-line JS shim sets the custom properties per element:
document.querySelectorAll<HTMLElement>('.bg-shape').forEach((el, i) => {
el.style.setProperty('--bg-xr', `${i % 3 === 0 ? 12 : i % 3 === 1 ? -8 : 6}px`);
el.style.setProperty('--bg-yr', `${i % 2 === 0 ? -14 : 10}px`);
el.style.setProperty('--bg-dur', `${4 + (i % 5) * 1.2}s`);
el.style.setProperty('--bg-delay', `${(i * 0.3) % 2}s`);
});
For scroll reveals, a 20-line IntersectionObserver adds .in-view to each section as it scrolls into view. CSS handles the rest:
.will-reveal .feature-card {
opacity: 0;
transform: translateY(28px);
transition: opacity 0.65s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.65s cubic-bezier(0.16, 1, 0.3, 1);
transition-delay: calc(var(--reveal-i, 0) * 0.1s);
}
.in-view .feature-card {
opacity: 1;
transform: translateY(0);
}
The --reveal-i index is set by JS so each card in a section gets a progressively longer delay. Same stagger math as GSAP, expressed as a CSS calc.
One thing GSAP handled for free that CSS doesn’t: prefers-reduced-motion. We added an explicit override:
@media (prefers-reduced-motion: reduce) {
#hero-badge, #hero-headline, #hero-sub, #hero-cta,
.bg-shape, .will-reveal .feature-card {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
}
Total JS for all three animation systems: ~40 lines. Total transferred: ~1 KB. GSAP was 113 KB. The animations feel identical.
The Numbers
| Metric | Before | After |
|---|---|---|
| Lighthouse Performance | 76 | 91 |
| Lighthouse Accessibility | 89 | 100 |
| Lighthouse Best Practices | 96 | 100 |
| Lighthouse SEO | 90 | 100 |
| LCP | 5.1 s | ~1.8 s |
| Screenshot images (total) | ~8 MB | ~320 KB |
| JS bundle (animations) | 113 KB | ~1 KB |
The biggest single win was the images. The caching fix doesn’t show up in a first-load Lighthouse run (it helps returning visitors), but it’s a correctness fix as much as a performance one — immutable on non-hashed filenames is a latent cache poisoning bug waiting to bite returning users after a deploy.
Accessibility Wins Along the Way
Chasing the 89 → 100 accessibility score required a few small fixes that were embarrassingly easy.
Missing <main> landmark. Layout.astro was rendering sections directly inside <body> with no <main> wrapper. Screen readers use landmarks to jump between sections of a page. A one-line fix:
<body>
<Navbar />
<main>
<slot />
</main>
<Footer />
</body>
Color contrast on small text. The .section-label eyebrow labels (the small uppercase labels above each section heading) were using --color-purple which is #A78BFA. That’s a 2.64:1 contrast ratio on white — WCAG AA requires 4.5:1 for text under 18px. We darkened it to #7C3AED (violet-600), which gives 6.14:1. Same visual feel, passes AA.
The carousel labels and footer link colors had similar issues and got the same treatment.
What’s Left
The icon.png in the LCP path is currently 30 KB (down from 1 MB — we shrunk it during this sprint). If LCP stays image-bound on the next real-world measurement, converting it to WebP would get it to ~8 KB.
The bigger caveat is that Lighthouse scores are a simulation. Real Chrome UX Report data has a 28-day lag before field measurements reflect the changes. We expect to see the LCP improvement show up in Search Console around mid-May.
The code for all of this is in the itayga123/marketing-kidsgamesapp repo. The CSS animations live in src/styles/global.css, the JS wiring is src/scripts/animations.ts, and the nginx changes are in nginx.conf at the repo root.