My home page was downloading 19.7MB every time it opened
My blog's home page was pulling 19.7MB, 93% of it images. How I got it down to 782KB with sharp resizing, Lottie and route splitting and font subsetting, all measured.
#Performance #Image optimization #sharp #Vite #Web fonts #React
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 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. 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 p