My home page was downloading 19.7MB every time it opened

Vite had been printing a warning on every build. It said one of my bundles was over 500KB. I kept ignoring it, and this time I finally decided to cut it down.

But once I actually measured, the bundle turned out to be only part of the problem. The real problem was somewhere else, and I had no idea until I measured.

First, measure it properly

When people say they're "reducing bundle size", they usually look at the file sizes in the build output. That's not the same as what a user actually downloads, though. Some files never get fetched at all, some arrive compressed, and images and fonts don't even show up in the bundle.

So I measured the bytes the browser actually received. Running this code on the page gives you the number.

const res = performance.getEntriesByType('resource');
const total = res.reduce((sum, e) => sum + (e.transferSize || 0), 0);

console.log((total / 1024).toFixed(0) + 'KB');

// Group it by type and you can see right away where the weight is
res.sort((a, b) => b.transferSize - a.transferSize).slice(0, 5)
   .forEach(e => console.log((e.transferSize/1024).toFixed(0) + 'KB', e.name));

I ran it on my blog's home page and got this.

Home total 19,682KB
   Images    18,345KB   <- this is the problem
   Fonts        691KB
   JSON         345KB
   JS           226KB
   CSS            6KB

It was 19.7MB. The first screen of a personal blog with twenty-odd posts was downloading about as much as a movie trailer. And 93% of that was images. I'd been worrying about the bundle all this time, and JS was 1% of the total. I had no idea until I measured it.

This is the home screen now. Cards are laid out 250px wide in the popular posts row at the top and the recent posts row below it. The problem was the pictures going into those small cards.

My home page was downloading 19.7MB every time it opened image 1

Culprit 1: a 2MB original photo in a 250px card

Looking at the image list, individual files were 2,134KB, 1,785KB and 1,688KB. They were the blog card thumbnails. I opened the code.

// BlogCard.tsx, one card is 250px wide
<div className='flex w-full aspect-[5/3] ...'>
  <img src={`${API_URL}/${blogData.files[0].path}`} ... />
</div>

I was putting the uploaded original photo as is into a 250px card. The browser downloads the whole 2MB image and then shrinks it with CSS. The home page has more than ten cards, so that added up to 18MB.

What I needed was a 250px image and I was downloading 2MB, so all it took was for the server to shrink it before sending. The backend was already serving the images anyway.

// app.js, originally it only served the originals like this
app.use('/files', express.static(path.join(__dirname, 'files')));

I slipped a resize layer in here. If you request a width like ?w=500, it converts the image to a webp of that size, caches the result on disk, and from then on just sends the file without converting again.

const sharp = require('sharp');

// Accepting any width would let the cache grow without limit. Only handle allowed values.
const ALLOWED_WIDTHS = [300, 500, 800, 1200];

async function imageResizeHandler(req, res, next) {
  const width = parseInt(req.query.w, 10);
  if (!width || !ALLOWED_WIDTHS.includes(width)) return next();  // fall through to the original

  const name = path.basename(req.path);          // block path traversal (../)
  const src = path.join(FILES, name);
  const cached = path.join(CACHE, `${name}_w${width}.webp`);

  if (!fs.existsSync(cached)) {
    await sharp(src)
      .rotate()                                   // apply EXIF rotation (without it portrait photos end up sideways)
      .resize({ width, withoutEnlargement: true })
      .webp({ quality: 80 })
      .toFile(cached);
  }

  res.set('Content-Type', 'image/webp');
  res.set('Cache-Control', 'public, max-age=31536000, immutable');
  return res.sendFile(cached);
}

There were a few things I paid attention to here. First, it only handles allowed widths. If requests came in as ?w=1, ?w=2, ?w=3, it would convert and write a cache file each time, and that's simply an attack that fills the disk. I also run the file name through path.basename once so that something like ../ can't be used to read another path.

EXIF rotation was a trap too. Without rotate(), a portrait photo taken on a phone ends up sideways after the resize. The original carries a separate piece of information saying "show this photo rotated 90 degrees", and that gets lost in the conversion.

On the frontend, all I changed was to have it request a size.

<img
  src={`${API_URL}/${blogData.files[0].path}?w=500`}   // 2x for retina
  loading='lazy'          // off-screen images load when you scroll to them
  decoding='async'
/>

This was the result.

1 thumbnail:  2,184,789 bytes  ->  10,834 bytes   (-99.5%)
All home images:  18,345KB  ->  142KB

That's 99.5%. A 2MB photo became 11KB and it looks the same in the card. It was always going to sit inside 250px, so of course it does.

And then I took the site down once

I installed sharp, restarted the server, and the whole site went down. This is what the log said.

Error: Could not load the "sharp" module using the linux-x64 runtime
- Please upgrade Node.js:
    Found 18.19.1
    Requires >=20.9.0

The latest sharp requires Node 20 or higher and this server was on Node 18. The trouble is that it throws at the require step. I was only trying to add image conversion and ended up with the entire blog not starting.

I fixed it by going down a version (0.34.5), but the more important part was the structure. The real problem was that it was written so one extra feature could kill the whole server. So I changed it like this.

let sharp = null;
try {
  sharp = require('sharp');
} catch (e) {
  console.error('[image] failed to load sharp, serving originals without resizing');
}

async function imageResizeHandler(req, res, next) {
  if (!sharp) return next();   // if it's unavailable, just pass through to the original
  ...
}

Now even if sharp dies, the images just go out as originals and the site still comes up. A nice-to-have feature has to be built so the service runs without it. I learned that one properly this time.

Culprit 2: animations baked whole into the bundle

With the images dealt with, I looked at the JS. In the build output a single index chunk was 1,041KB. There was no way I had written that much code, so I dug through the file to see what was in it, and found a single string 388KB long.

It was a Lottie animation. I had used Lottie for things like the top bar logo, the splash and the loading indicator, and this is how I was using them.

import animationData from '../../lotties/logo.json';   // 548KB
import animationData from './splash.json';              // 306KB

When you import a JSON file, its contents go into the JS bundle whole, as a string. The five of them added up to 1.1MB, and that was going down with the JS every time, even though animations are extras that can show up after the screen has rendered.

So I moved the JSON into the public folder and fetched it when needed.

const cache = new Map();

export const useLottieJson = (name) => {
  const [data, setData] = useState(() => cache.get(name) ?? null);

  useEffect(() => {
    if (cache.has(name)) return;
    fetch(`/lotties/${name}.json`)
      .then((res) => res.json())
      .then((json) => { cache.set(name, json); setData(json); })
      .catch(() => {});   // the screen still renders even if the animation fails to load
  }, [name]);

  return data;
};

The renderer itself (lottie-web) is 77KB too, and since the top bar is on every page, it always came along as well. So I deferred the library with lazy too.

const Lottie = lazy(() => import('lottie-react'));

const LazyLottie = ({ name, style, fallback = null }) => {
  const animationData = useLottieJson(name);
  if (!animationData) return <>{fallback}</>;   // fallback until it arrives

  return (
    <Suspense fallback={<>{fallback}</>}>
      <Lottie animationData={animationData} style={style} />
    </Suspense>
  );
};

Culprit 3: I wasn't splitting a single route

The route config was importing every page statically.

import Home from "../routes/Home";
import Write from "../routes/admin/Write";        // admin post editor
import AdminBlog from "../routes/admin/AdminBlog";
// ... all static imports

That way, someone who only opens the home page downloads everything, including the code for the admin writing page. That page uses the Quill editor, which is 55KB even compressed. People who came to read the blog were downloading an editor that only I use.

// Keep only Home static, make the rest lazy
const Blogs = lazy(() => import("../routes/Blogs"));
const Write = lazy(() => import("../routes/admin/Write"));
const AdminBlog = lazy(() => import("../routes/admin/AdminBlog"));

But even after splitting the routes, Quill kept coming along on the post detail page. That seemed odd, so I looked, and the screen that displays a post was mounting the editor.

// ContentBody.tsx, this is the screen for reading a post
<ReactQuill value={content} readOnly={true} theme="bubble" />

It was running as readOnly, so none of the editing features were in use. I was mounting an entire editor just to show some HTML. I changed it to render the same DOM structure the editor produces.

<div className='quill'>
  <div className='ql-container ql-bubble'>
    <div className='ql-editor' dangerouslySetInnerHTML={{ __html: html }} />
  </div>
</div>

That removed the 55KB of Quill from the post page. Not a single pixel changed on screen.

Culprit 4: a PNG hiding inside an animation file

Once the Lottie JSON was out of the bundle, the JSON itself caught my eye. logo.json alone was 548KB. Even Brotli only got it down to 309KB, and when something compresses badly, it means there's already-compressed data inside. I opened it up.

assets: 2 | base64 images: 1
size taken by base64 images: 388KB  (71% of total)
dimensions: 570x505

A 570×505 PNG was embedded in it as base64. When the animation was exported to Lottie, the image went into the file whole. On top of that, base64 is 33% bigger than the original. A 291KB PNG had turned into a 388KB string.

I pulled the image out, compressed it to webp, and changed the Lottie file to reference an external file.

for (const asset of json.assets) {
  if (!asset.p || !asset.p.startsWith('data:')) continue;

  const raw = Buffer.from(asset.p.split(',')[1], 'base64');
  await sharp(raw).webp({ quality: 82 }).toFile(outPath);

  asset.e = 0;                      // 0 = external file
  asset.u = '/lotties/images/';     // lottie-web fetches it from u + p
  asset.p = 'logo_image_0.webp';
}
image_0: PNG 291KB -> webp 20KB
logo.json: 548KB -> 160KB

Culprit 5: a font carrying characters I never use

The last one was the font. I use a pixel font called Galmuri, and the two files added up to 716KB.

Korean fonts are heavy because of the number of characters. Hangul syllables alone come to 11,172, and if Chinese characters and Japanese characters are in there too, the file just keeps growing. So I made a subset that strips out the glyphs I don't use.

I played it safe with the character set. I put in every actual post in the DB, the UI text hardcoded in the frontend source, and all 11,172 Hangul syllables. If I cut it based only on the current posts, a character used for the first time in a later post could come out broken.

const subsetFont = require('subset-font');

// 1) actual posts in the DB + 2) UI text in the source + 3) all Hangul syllables + 4) ASCII/symbols
const chars = new Set([...dbText, ...srcText, ...ascii, ...punct, ...hangulSyllables()]);

const buf = await subsetFont(fs.readFileSync(ttf), [...chars].join(''), {
  targetFormat: 'woff2',
});
Galmuri11.woff2:       535KB -> 145KB  (-73%)
Galmuri11-Bold.woff2:  181KB -> 124KB  (-31%)

Even with all of Hangul included, it shrank by 73%. That means the original was packed with characters my blog never uses even once, like Chinese characters and kana.

The result

                Before       After
Home total    19,682KB  ->   782KB    (-96%)
  Images      18,345KB  ->   142KB    (-99.2%)
  Fonts          691KB  ->   270KB    (-61%)
  JS             682KB  ->   226KB    (-67%)
  Lottie JSON    345KB  ->    49KB    (-86%)

index chunk    1,041KB  ->    32KB    (-97%)

19.7MB became 782KB. And yet nothing on screen changed. The thumbnails look the same, the top bar animation still plays, and the pixel font is the same. I opened it on both desktop and mobile to check.

I measured again two months later

While revising this post, I measured the home page again the same way. In the meantime the posts had gone from 21 to 35, the home page had gained two rows for popular posts and recent posts, and the AdSense script had been added.

My home page was downloading 19.7MB every time it opened image 2

The total is 1,481KB. That's up from the 782KB in July, but there are two reasons for the increase and both are within what I expected. One is 288KB of ad script. That isn't my bundle, it comes down from Google's side, so it's not something I can shrink. Without the ads it's 1,193KB. The other is that images went from 142KB to 616KB. The cards went from ten or so to thirty-six, and each one comes down at somewhere between 6KB and 48KB. It's not 2MB per card like when I was serving the originals as is. They're still going through the resize layer, so the structure hasn't broken down.

One thing that does stand out is logo.png. It's a static file in the public folder, so it skips the resize layer and the 95KB original goes out as is. It's the biggest of the images. Converting it to webp the next time I work on this will take care of it.

JS is 221KB counting only my bundle, almost the same as the 226KB in July. I pulled the list of chunks downloaded on the home page and it goes lottie-web, react-dom, framer-motion, with no quill. That means splitting the admin writing screen out with lazy is still holding. If you measure the whole dist folder, the quill chunk is in there, but that means the file exists, not that visitors download it. The 270KB of fonts isn't admin-only either, it's the pixel font the whole blog uses, so it's right for the home page to load it.

In the end, this kind of work wasn't something you measure once and finish. The numbers move every time posts are added, the screen changes or an external script gets attached. I think the habit of pasting that one line of code back into the console whenever I change something is what I'll keep the most from this.