Optimizing Bundle Size for Edge Runtime Deployment
This guide is part of Edge Bundle Optimization Techniques. It walks the concrete steps to drive a single deployment artifact under the provider cap.
CI pipelines fail with Script too large or Function payload too large when edge bundles breach platform ceilings. Each V8 isolate spikes initialization latency as the engine must parse, allocate heap, and JIT-compile oversized payloads. Memory pressure appears early when bundlers inject full Node.js polyfill suites into runtimes with 128 MB caps—see polyfill strategies for Node.js APIs at the edge for trimming those shims.
Exact platform limits:
- Cloudflare Workers: 1 MB uncompressed script
- Vercel Edge Middleware: 1 MB uncompressed
- Netlify Edge Functions: 20 MB
- AWS Lambda@Edge: 1 MB (viewer-facing) / 50 MB (origin-facing) zipped
These are deployment-time limits on the uncompressed script file, not the over-the-wire payload to end users.
Root Causes of Bundle Inflation
Default bundler configurations shim Node.js built-ins aggressively. Webpack 4 and some Vite configurations inject crypto-browserify, buffer, stream-browserify, and path-browserify by default to maintain backward compatibility. These shims add 100 KB–2 MB of dead weight.
Missing "sideEffects": false prevents tree-shaking. Without this declaration, bundlers assume every export from a package may have side effects on import, and retain the entire module.
CommonJS packages cannot be statically analyzed. A require() call inside a CJS module forces the entire module into the bundle.
Transitive bloat from indirect dependencies is the hardest to detect. A small utility package that depends on lodash (full) will inflate your bundle by 70–500 KB.
Step 1: Profile the Bundle
Before optimizing, measure precisely where bytes are going:
# Cloudflare Workers
npx wrangler deploy --dry-run --outdir=dist
npx esbuild-bundle-visualizer --metafile dist/meta.json
# Next.js
ANALYZE=true next build
# Vite
npx vite build && npx rollup-plugin-visualizer --open
Flag any single module exceeding 50 KB as a candidate for replacement or lazy loading.
Step 2: Replace Node.js Built-ins
Swap synchronous Node APIs for Web Standards before the bundler runs. This eliminates shim injection entirely:
| Replace | With |
|---|---|
crypto.createHash('sha256') |
crypto.subtle.digest('SHA-256', data) |
Buffer.from(str, 'base64') |
Uint8Array.from(atob(str), c => c.charCodeAt(0)) |
uuid.v4() |
crypto.randomUUID() |
axios / node-fetch |
Native fetch |
moment |
Intl.DateTimeFormat or date-fns (tree-shaken) |
Configure your bundler to externalize Node built-ins so they are never bundled:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
target: 'esnext',
rollupOptions: {
external: ['node:crypto', 'node:fs', 'node:path', 'node:http', 'node:net', 'node:tls'],
},
},
});
Step 3: Enforce Tree-Shaking and ESM Compliance
{
"type": "module",
"sideEffects": false
}
Convert all entry points to ESM. Use named imports:
// Correct: tree-shakeable
import { parseISO } from 'date-fns';
// Incorrect: imports the entire library
import dateFns from 'date-fns';
For routes or features that are rarely used, use dynamic import() to defer loading:
export async function handleEdgeRequest(req: Request) {
const locale = new URL(req.url).searchParams.get('lang') ?? 'en';
// Only the required locale file is fetched and parsed
const { messages } = await import(`./locales/${locale}.js`);
return new Response(JSON.stringify(messages), {
headers: { 'Content-Type': 'application/json' },
});
}
Step 4: Configure Aggressive Minification
// esbuild.config.mjs
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/edge-handler.ts'],
bundle: true,
minify: true,
minifyWhitespace: true,
minifyIdentifiers: true,
minifySyntax: true,
target: ['es2022'],
platform: 'browser',
define: {
'process.env.NODE_ENV': '"production"',
// Strip debug-only code paths
'__DEV__': 'false',
},
external: ['node:*'],
outdir: 'dist/edge',
metafile: true,
});
Proper minification typically reduces an already-tree-shaken bundle by 20–40%. If minification reduces your bundle by less than 15%, investigate for dead code or inlined assets that aren’t being eliminated.
Step 5: Automate Size Gates in CI
Block PRs that exceed the deployment limit:
- name: Enforce Edge Bundle Budget
run: |
SIZE=$(wc -c < dist/edge/index.js)
LIMIT=1048576
if [ "$SIZE" -gt "$LIMIT" ]; then
echo "ERROR: Edge bundle exceeds 1 MB limit. Current: ${SIZE} bytes"
exit 1
fi
echo "OK: ${SIZE} bytes"
For Vercel, the next build output includes Edge Runtime bundle sizes per route. Parse these in CI:
ANALYZE=true next build 2>&1 | grep -E "Edge Runtime.*kB"
Local vs Production Behavior
Local dev servers disable minification, skip byte limits, and mock edge routing. They systematically undercount real-world bundle size.
Key differences in production:
- Hard payload limits are enforced at deployment time.
- Brotli/Gzip compression is applied to transit payloads (not deployment artifacts).
Cache-Controldirectives are strictly honored; missingVary: Accept-Encodingcauses cache-bypass loops.
Always validate against a staging deployment with production-equivalent bundler settings before merging bundle optimization changes:
# Deploy to a preview environment (not local dev) to validate production bundler behavior
vercel
curl -I https://preview-url.vercel.app/api/endpoint | grep -E "(content-length|cache-control|content-encoding)"
| Behavior | Local dev | Production / preview |
|---|---|---|
| Minification | Off by default | On |
| Byte-size limit | Not enforced | Hard reject at deploy |
| Edge routing | Mocked | Real isolate placement |
| Compression | None | Brotli/Gzip on transit only |
| Node polyfill injection | May resolve under Node | Fails if shim absent |
Validate the Size Gate with Vitest
Assert the built artifact stays under the cap as part of the test suite, not just the CI shell step:
// tests/bundle-size.test.ts
import { describe, it, expect } from 'vitest';
import { statSync } from 'node:fs';
describe('edge bundle budget', () => {
const LIMIT = 1_048_576; // 1 MB uncompressed
it('keeps the built worker under the 1 MB cap', () => {
const { size } = statSync('dist/edge/index.js');
expect(size, `bundle is ${size} bytes`).toBeLessThan(LIMIT);
});
});
This test reads the artifact a production bundler emitted, so it is a build-time check; keep it out of the edge runtime test pool.
Named Pitfalls
- Measuring the compressed size. The limit is on the uncompressed artifact. Fix: gate on
wc -cof the built file, not the gzipped transit payload. - Trusting local dev size. Local servers skip minification and byte limits. Fix: validate on a preview deploy.
- Default imports.
import dateFns from 'date-fns'pulls the whole library. Fix: use named imports. - Unflagged side effects. Missing
"sideEffects": falsedefeats tree-shaking. Fix: declare it inpackage.json. - Transitive
lodash/moment. A small util drags in a large dependency. Fix: trace with--analyzeand replace the root package.
Production Deployment Checklist
- Node built-ins swapped for Web equivalents;
node:* -
"type": "module"and"sideEffects": false - Rarely used paths deferred with dynamic
import() - Minification enabled with
NODE_ENV=production
Frequently Asked Questions
What exactly does the platform measure against the limit?
The uncompressed size of the built JavaScript artifact at deploy time. Brotli/Gzip applies only to the transit payload sent to end users and does not lower the deployment measurement. Gate on wc -c of the built file.
Why does my bundle pass locally but fail in CI deploy?
Local dev servers disable minification and skip byte-size enforcement, so they undercount real-world size. The deploy step runs the production bundler and enforces the cap. Validate against a preview deployment before merging.
Does dynamic import() reduce the deployed bundle size?
It defers parsing and execution of rarely used code, lowering cold-start work and, on platforms that split chunks, the main entry size. The deferred chunk still ships, so it helps initialization latency more than the hard total in single-file deployments.
How much should minification shrink a bundle?
A tree-shaken bundle typically drops 20–40% under aggressive minification. If it falls by less than 15%, suspect inlined assets or dead code the minifier cannot eliminate, and investigate before assuming you are at the floor.
What is the first thing to cut on a 1.8 MB bundle?
The largest transitive dependency. Replacing one offender such as lodash (full), moment, or AWS SDK v2 with a native Web API or a modular package usually clears more weight than every other technique combined.
Related
- Edge bundle optimization techniques
- Polyfill strategies for Node.js APIs at the edge
- Supported Web APIs in edge runtimes
- Managing cold starts in serverless environments
Conclusion
Bundle size optimization for edge runtimes follows a deterministic process: profile to find what’s large, replace Node.js APIs with Web equivalents to eliminate shims, enable ESM and sideEffects: false for effective tree-shaking, minify aggressively, and gate bundle size in CI. A 1 MB uncompressed limit sounds generous until a single transitive lodash dependency consumes half of it. The most impactful single action is usually replacing one large transitive dependency with a native Web API or a purpose-built edge-compatible package.