Reduce Serverless Bundle Size: How We Cut Ours by 36%
Oct 20268 min read
How we reduce serverless bundle size on our own site: measure first, then move work to build time. 2,940 to 1,871 KiB against a 3 MiB cap.
- Serverless
- Bundle size
- Build time
To reduce serverless bundle size, measure before you cut. Have the bundler write a metafile, sort it by compressed size, and move anything that only changes at deploy time into the build. That took this site's server bundle from 2,940 KiB to 1,871 KiB gzipped, about 36% smaller, against a 3 MiB limit.
I'm Dheer, co-founder and CTO at mkdir (mkdirhq.com). On 2026-10-11 we moved this site from our previous host to a serverless platform. Its free plan caps the server bundle at 3 MiB compressed, which is 3,072 KiB. The first build that worked came to 2,940 KiB gzipped, or 12,445 KiB before compression.
It fit, with about 4% of headroom. That is very little room for the next server-side dependency. So we stopped and found out what was in those 2,940 KiB before changing anything.
What counts toward a serverless bundle size limit?
Server-side code counts: the page rendering code, the route handlers, and every dependency either of them imports. On this platform the number that matters is the compressed one. Gzip is a file format built on the Deflate algorithm, and the platform measures the upload after that compression.
Static assets do not count. Images, fonts and the JavaScript sent to the browser are served as files, outside the server bundle. This matters because the heaviest things a visitor downloads from our site are 3D scenes, which made them the obvious suspect.
They were already excluded. The 3D scenes and the other browser-only code never entered the server bundle, so we left them alone. Trimming them would have meant shrinking something the limit never looked at.
How do you find what is taking up space in a server bundle?
Ask the bundler. Most bundlers can write a metafile, a machine-readable list of every module in the output and how many bytes each one contributes. Public analyzers will draw that file as a treemap. Ours showed what was in the bundle, and three entries stood out.
Uncompressed size is a poor guide here. A large block of repetitive JavaScript compresses well. A WebAssembly binary or a font file barely shrinks. Sort by the compressed figure, because that is what the platform counts.
| What the metafile showed | Size | Why it was there |
|---|---|---|
| Image renderer, WebAssembly module | 519 KiB gzipped | Drew social share cards on request |
| Layout engine module | 28 KiB gzipped | Part of the same image renderer |
| Bundled font | 58 KiB gzipped | Text on the share cards |
| Content-management client library, bundled twice | About 1.2 MB uncompressed in total | One copy for page rendering, one for a route |
| The whole worker | Uploaded unminified | Minification was not turned on |
The largest single item was the image renderer that drew our social share cards. A share card is the picture a chat app or social network shows when someone pastes a link. The page points to it with an image tag from the Open Graph protocol, and the renderer produced that picture whenever a crawler asked for it.
Its WebAssembly module alone was 519 KiB gzipped. On top of that sat a layout engine module at 28 KiB, a bundled font at 58 KiB, and the renderer's own JavaScript. All of it shipped inside the server bundle to produce images that only change when a post changes.
The second finding was a content-management client library, bundled twice. One copy served page rendering and the other served a route, for about 1.2 MB uncompressed in total. Our posts live as files in the repository, and the content-management reader is optional. We were carrying two copies of a library to send one kind of query.
The third finding was the cheapest. The worker was being uploaded unminified.
How do you reduce serverless bundle size without changing the site?
We made three changes, in order of effort. None of them removed a feature a visitor could see.
| Stage | Server bundle, gzipped | Saved at this step |
|---|---|---|
| First working build | 2,940 KiB | Baseline, about 4% headroom |
| Minify the worker, replace the client library with one fetch call | 2,531 KiB | 409 KiB |
| Draw share cards at build time, delete the on-request routes | 1,871 KiB | 660 KiB |
| Total | 2,940 to 1,871 KiB | About 36% smaller, about 39% headroom |
1. Minify the worker
Minification strips whitespace and shortens names, and gzip then has less to compress. It costs nothing at runtime, so it went first.
2. Replace the client library with one fetch call
The library did one job for us: send a query to an HTTP API and return the result. We wrote a small reader that builds the same URL, attaches the same query and parameters, and calls fetch. It sends the same queries the library sent. If the request fails or the reader is not configured, the callers fall back to the posts in the repository, as they did before.
A general-purpose client has to cover every feature of its API. We needed a single read. Together with minification, this took the bundle from 2,940 to 2,531 KiB.
3. Draw the share cards at build time
This was the big one. A script now runs before every build and draws each card into a static PNG file. It produces 43 images: one default card and one per blog post. Then we deleted the on-request routes, and the renderer left the server bundle with them. That took it from 2,531 to 1,871 KiB.
Should share cards be drawn at build time or on request?
It depends on when the image can change. Ours show a post title, a category and the logo. Every new post on this site is a deploy, so there is no moment when a card needs to change between builds. Build time is the right time to draw them.
On-request rendering makes sense when the picture depends on data that changes without a deploy, such as a live price or a user's profile. Nothing on our cards works that way.
Two details kept this change safe. First, the build script calls the same renderer the routes used, with the same layout. We compared the output with the old cards: pixel-identical, and the same file size as the on-request versions. The renderer still exists, but it now runs on the build machine and never ships to the server.
Second, links that people had already shared still pointed at the old card addresses. We added permanent redirects from each old URL to its new file. A link pasted into a chat months ago still resolves to the right picture.
What broke when we moved hosts?
Two things, and neither had anything to do with size.
Plain http served pages directly
Our previous host had been redirecting plain http to https and adding the HSTS header without being asked. We had never configured either, so neither appeared anywhere in our code. After the move, a request over http got the page back directly, with no redirect.
The fix was two entries in the site config. One is a permanent redirect from http to https, limited to the production hostname so it stays off local development, where every request is http. The other is the Strict-Transport-Security header with a max-age of two years.
You need both. HSTS tells a browser to use https for every future visit, but browsers ignore the header when it arrives over plain http. A first-time visitor on http only learns the rule after the redirect has moved them to https.
The first automated build failed at install
The platform builds each push to the main branch on its own build image. The first automated build stopped at the install step, before any of our code ran. The cause was the lockfile, the file that records the exact version of every dependency.
We had generated it with a newer major version of the package manager than the build image runs. The newer version left out some optional entries that the older one insists on, so the older one refused a strict install.
Regenerating the lockfile with the build image's version fixed it, and the first automatic build after that passed. The build only deploys when it passes, so the failure never reached the live site. We now check the lockfile against that version after every dependency change.
How do you know a migration changed nothing?
A smaller bundle is worth nothing if the pages changed on the way. We recorded every page before the move and again after it. That covered all 61 pages, with seven checks each: title, description, canonical, robots tag, main heading, structured data count and word count.
All 61 were identical. The comparison is mechanical on purpose. A dropped canonical or a missing heading is easy to miss by eye and easy to catch in a diff.
What would we do the same way again?
Measure first. The obvious suspect, the 3D scenes, was never in the bundle. What mattered was a renderer sitting on the server, a library included twice, and a worker left unminified. The metafile showed all of that in one pass.
Ask of each server-side dependency when its output can change. If the answer is only at deploy, it belongs in the build. That one question removed 660 KiB.
Write down what your host does for you before you leave it. The http redirect and the HSTS header were behaviour we relied on and had never written. A short list of those defaults would have caught it before the move.
And keep the number visible. The limit and the command that prints the current size are both in our repository notes, with a rule to measure before adding a server-side dependency. At 1,871 KiB we have about 39% of headroom, and we would like to notice before it is gone.
We work the same way on client builds, whether the job is an AI agent or an internal tool: find out where the weight is, then decide what to move.
Sources
- Sizes, counts and the page comparison: our own measurements on this site's build, taken 2026-10-11
- Strict-Transport-Security header, including that browsers ignore it over plain HTTP: MDN Web Docs (developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security)
- gzip as a file format based on the Deflate algorithm: MDN Web Docs glossary (developer.mozilla.org/en-US/docs/Glossary/gzip_compression)
- WebAssembly modules: MDN Web Docs (developer.mozilla.org/en-US/docs/WebAssembly/Guides/Concepts)
- The og:image tag behind social share cards: The Open Graph protocol (ogp.me)
- An example of a public analyzer that draws a bundler metafile as a treemap: esbuild bundle size analyzer (esbuild.github.io/analyze)