Edge Bundle Optimization Techniques

Edge runtimes reject or penalize oversized bundles. Cloudflare Workers and Vercel Edge Middleware both enforce a 1 MB uncompressed limit; Netlify Edge Functions allow 20 MB. Compression applied during transit does not count toward these deployment thresholds—the uncompressed size of your bundled JavaScript is what matters.

Beyond hard rejection, bundle size directly correlates with initialization latency. Each V8 isolate must parse, compile, and JIT-compile every function in the bundle before the first request executes. A 900 KB bundle adds approximately 15–25 ms to cold start initialization on a typical edge isolate. Reducing the bundle to 200 KB reduces that overhead proportionally.

This guide covers the techniques and provider limits. For a step-by-step reduction walkthrough that drives a real artifact under 1 MB, follow optimizing bundle size for edge runtime deployment.

Edge bundle optimization pipeline A bundle passes through audit, dependency replacement, tree-shaking, minification, and a CI size gate before deployment. Audit --analyze Replace deps lodash, moment Tree-shake ESM, sideEffects Minify esbuild CI gate < 1 MB Each stage shrinks the uncompressed artifact and the cold-start parse cost
Bundle optimization is a pipeline: audit weight, replace heavy dependencies, tree-shake, minify, and gate the final size in CI.

Tree-Shaking and ESM-First Resolution

Tree-shaking removes unused exports but requires ESM modules with static import/export declarations. CommonJS modules (require(), module.exports) cannot be statically analyzed and are included in their entirety.

Practical steps:

  • Set "type": "module" and "sideEffects": false in your package.json.
  • Use import { fn } from 'library' not import library from 'library' for partially-used packages.
  • Avoid barrel files (index.ts that re-exports everything) — they force bundlers to retain all exports.
Barrel import versus named submodule import Importing through a barrel index file makes the bundler retain every re-exported module because it cannot prove the rest are unused. Importing the submodule path directly leaves a single module in the graph and drops the others. Before: import from the barrel After: import the submodule path edge-handler.ts lib/index.ts (barrel) date.ts csv.ts crypto.ts + 3 more modules re-exported 412 KB retained edge-handler.ts date.ts csv.ts crypto.ts 5 modules never referenced — dropped 38 KB retained
The barrel is not itself large — the cost is that it makes every sibling module reachable, so the bundler has no basis for dropping any of them.
// ESM static import (tree-shakeable)
import { verifyJWT } from '@edge/jwt';

// Conditional import for heavy, rarely-used operations
let cryptoLib: typeof import('crypto-js') | null = null;

export async function handler(request: Request): Promise<Response> {
  const url = new URL(request.url);

  if (url.pathname === '/health') {
    return new Response('OK', { status: 200 });
  }

  // Load heavy dependency only when the specific route requires it
  if (url.searchParams.has('legacy_hash')) {
    cryptoLib ??= await import('crypto-js');
    const hash = cryptoLib.SHA256(url.searchParams.get('legacy_hash')!).toString();
    return new Response(JSON.stringify({ hash }), {
      headers: { 'Content-Type': 'application/json' },
    });
  }

  return new Response('Not Found', { status: 404 });
}

How Bundlers Decide What to Keep

Tree-shaking is not a size optimization applied at the end of a build; it is a reachability question answered while the module graph is constructed. The bundler starts at the entry point, follows every static import, and marks each binding it can prove is referenced. Everything unmarked is a candidate for removal — but only if removing it is provably safe.

That proof is where real bundles fail. A module’s top-level statements may have observable effects: registering a global, patching a prototype, calling Object.defineProperty on a shared object. A bundler that dropped such a module would change program behavior, so by default it keeps every module it imports even when none of its exports are used. "sideEffects": false is the package author’s assertion that no such effects exist, which is why it unlocks so much removal — and why it is occasionally a lie that produces a bundle 40 KB smaller and subtly broken.

Three patterns routinely defeat marking even in well-formed ESM:

  • Namespace imports. import * as utils from 'lib' followed by utils[name]() makes the referenced export a runtime value. No static analysis can narrow it, so the entire namespace is retained.
  • Re-export chains. A barrel that does export * from './csv' makes every symbol in csv.ts reachable from the barrel’s module record. Named re-exports (export { parseCsv } from './csv') are analyzable; star re-exports are not, in the general case.
  • Class static initializers. A static field that calls a function runs at definition time, which counts as a side effect of evaluating the module and pins the class in place.

Where a call is genuinely pure but the bundler cannot see it, a /*#__PURE__*/ annotation on the call expression tells the minifier it may be dropped if its result is unused. Library authors use this on factory calls; you can use it on your own module-scope constructions.

The practical consequence is an ordering rule. Fix reachability before you fix bytes: a build where sideEffects: false is missing will show almost no improvement from minification, because minification compresses the code that tree-shaking should have removed entirely. Confirm that the module count in your metafile drops before you tune minifier flags.

Provider Bundle Limits

Provider Uncompressed Limit Key Constraints
Cloudflare Workers 1 MB ESM imports; nodejs_compat flag for Node shims
Vercel Edge Middleware 1 MB node_modules excluded from bundle; Edge Config for runtime data
Netlify Edge Functions 20 MB Deno import maps; explicit polyfill bundling

Replacing Heavy Dependencies

These are the most common bundle-size offenders and their replacements:

Heavy dependency Replacement Savings
lodash (full) Individual functions or native equivalents 70–500 KB
moment Intl.DateTimeFormat, date-fns (tree-shaken) 200–300 KB
axios Native fetch 10–50 KB
node-fetch Native fetch 10–50 KB
uuid crypto.randomUUID() 5–15 KB
AWS SDK v2 @aws-sdk/client-* v3 (modular) 200–800 KB
jsonwebtoken jose (ESM, edge-compatible) 50–200 KB
Bytes freed per dependency swap Replacing AWS SDK v2 with the modular v3 clients or full lodash with native equivalents frees hundreds of kilobytes, while swapping axios or uuid frees only tens. The two largest swaps recover more of the 1 MB budget than every other technique combined. Uncompressed kilobytes removed by one swap (upper bound) AWS SDK v2 → v3 lodash → native moment → Intl jsonwebtoken → jose axios → fetch uuid → randomUUID 800 KB 500 KB 300 KB 200 KB 50 KB 15 KB Green swaps alone can free more than half of a 1 MB Cloudflare budget; minification rarely frees 40% of what remains.
Ranking swaps by bytes freed rather than by effort explains why dependency replacement outranks every build-flag change on the same bundle.

Minification and Build Configuration

// 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"',
  },
  external: ['node:*'], // Exclude all node: prefixed modules; use nodejs_compat instead
  outdir: 'dist/edge',
  metafile: true,
});

The metafile: true option produces a JSON file that maps every module to its byte contribution. Feed it to the esbuild bundle analyzer:

npx esbuild-bundle-visualizer --metafile dist/meta.json

Vite Configuration for Edge Deployment

// 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'],
      output: {
        inlineDynamicImports: false,
        manualChunks: (id) => {
          if (id.includes('node_modules')) return 'vendor';
        },
      },
    },
  },
});

CI Bundle Size Gate

Automate rejection of PRs that exceed the 1 MB limit:

# .github/workflows/edge-bundle-audit.yml
name: Edge Bundle Size Gate
on: [pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx wrangler deploy --dry-run --outdir=dist
      - name: Validate Uncompressed Size
        run: |
          SIZE=$(wc -c < dist/worker.js)
          LIMIT=1048576
          if [ "$SIZE" -gt "$LIMIT" ]; then
            echo "Bundle exceeds 1 MB uncompressed: $SIZE bytes"
            exit 1
          fi
          echo "Bundle OK: $SIZE bytes"

For Next.js / Vercel builds, use the @next/bundle-analyzer package to generate a treemap and identify bloated imports:

ANALYZE=true next build

Debugging Transitive Dependencies

Indirect imports from third-party packages are the most common cause of unexpected bundle bloat. Identify them:

# Cloudflare: generate metafile during dry-run
wrangler deploy --dry-run --outdir=dist
npx esbuild dist/worker.js --bundle --analyze=verbose 2>&1 | head -60

# Vite: generate bundle stats
npx vite build --mode production && npx rollup-plugin-visualizer

Flag packages that import lodash, moment, crypto-browserify, or buffer as transitive dependencies. Replace the root package with an edge-compatible alternative.

Read the analysis by import path, not by package name. A metafile attributes bytes to the module that emitted them, so a 300 KB entry under node_modules/moment/moment.js tells you what is large but not who asked for it. The --analyze=verbose output includes the chain that made each module reachable, and that chain is the actionable part: a 12 KB helper package sitting three levels up is usually the only thing you need to change. Track the size delta per commit rather than the absolute number — a bundle that grew 180 KB in one merge points at a specific dependency addition far more reliably than a periodic audit of a bundle that has been drifting upward for months.

When no single package dominates, the diagnosis is usually structural rather than dependency-driven: a missing sideEffects declaration, a barrel import, or a bundler still targeting a Node platform and injecting shims. Confirm the module count in the metafile, not only the byte total; a build retaining four hundred modules for a handler that imports six is a reachability problem, and no amount of dependency swapping will fix it.

Deployment Decision Flow

Phase Action Gate
Audit Run --analyze; map dependency weight; flag non-ESM packages If node_modules > 60% of payload, enforce sideEffects: false
Refactor Replace heavy libraries; add dynamic import() for non-critical paths No process, fs, path without guards
Bundle Configure minification; NODE_ENV=production stripping Minification should reduce payload by > 30%
Validate CI size gate; local edge emulation for init latency Uncompressed < provider limit
Deploy Staging first; monitor cold-start metrics TTFB < 100 ms on cold start; memory < 80 MB

For step-by-step bundle reduction targeting sub-1 MB deployment artifacts, see optimizing bundle size for edge runtime deployment.

Worked Example: A 1.4 MB Auth Worker

A concrete ledger makes the ordering argument sharper than percentages do. The worker below authenticates requests, signs short-lived download URLs, and emits structured logs. It deployed at 1.42 MB uncompressed and was rejected by Cloudflare.

Step Change Before After
Baseline 1420 KB
1 jsonwebtoken + crypto-browserifyjose 1420 KB 1105 KB
2 @aws-sdk/client-s3 v2 → v3 modular S3Client + getSignedUrl 1105 KB 690 KB
3 moment + moment-timezoneIntl.DateTimeFormat 690 KB 402 KB
4 "sideEffects": false; barrel import replaced with submodule paths 402 KB 331 KB
5 esbuild minify + NODE_ENV define 331 KB 214 KB

Two details are worth reading closely. Step 1 removed more than the jsonwebtoken package weighs, because dropping it also dropped the crypto-browserify shim the bundler had injected to satisfy its require('crypto') call — replacing a Node-dependent library removes both the library and its polyfill tail. Step 3 removed 288 KB from a single import: moment-timezone bundles the IANA timezone database, and Intl.DateTimeFormat reads the same data from the runtime at zero bundle cost.

Note also what step 5 did not do. Minification cut 35% — squarely in the expected 20–40% band — but that 117 KB arrived last and would have been 400 KB of compressed dead weight if run first. Minification compresses whatever survives; it never asks whether the code should be there.

Edge Cases That Defeat the Obvious Fix

Dynamic imports with a template literal expand, not shrink. await import('./locales/' + lang + '.js') forces the bundler to include every file matching that pattern, since it cannot know which one is needed. Forty locale files become forty chunks in the deployment artifact. Use an explicit map from key to static import when the set is small, or fetch the data from KV instead of bundling it.

sideEffects: false on your own package can silently break polyfills. If any module in your source tree exists solely for its import-time effect — installing a global, registering a route, calling a telemetry initializer — declaring the whole package side-effect-free authorizes the bundler to delete it. Scope the declaration with an array ("sideEffects": ["./src/register-globals.ts"]) rather than a blanket false.

define only replaces the exact expression you name. Setting 'process.env.NODE_ENV': '"production"' strips if (process.env.NODE_ENV !== 'production') blocks but does nothing for process.env.FEATURE_X, and a destructured const { NODE_ENV } = process.env is invisible to the replacement entirely. Check the built artifact for surviving process.env references before assuming dead code was stripped.

Non-JavaScript modules count against the limit. Cloudflare measures the total of all modules in the deployment: WASM binaries, imported text and JSON files, and any inlined asset. A 700 KB WASM module leaves you 300 KB of JavaScript, regardless of how well the JavaScript tree-shakes. Source maps are the exception — they are uploaded separately and excluded from the script size.

Type-only imports must be erased. import { User } from './types' compiles to a real runtime import under some TypeScript configurations, dragging the module into the graph even though only a type was used. Write import type { User } from './types' so the statement is removed at compile time rather than relied upon to shake out later.

Common Pitfalls

Symptom Cause Fix
Script too large on deploy Uncompressed bundle exceeds the 1 MB Cloudflare/Vercel cap Replace the heaviest transitive dependency; gate size in CI
Tree-shaking removes nothing Missing "sideEffects": false or a CJS package Declare sideEffects: false; switch to an ESM build of the dependency
Whole library pulled in for one helper Default import or a barrel file Use named imports; import the specific submodule path
Bundle balloons after adding a small util Transitive lodash/moment dependency Trace with --analyze; swap the root package for an edge-compatible one
Cold start regresses without size change A polyfill suite added init-time work Scope polyfills; prefer native Web APIs

Runtime-Constraints Checklist

Frequently Asked Questions

Does the 1 MB limit apply to the compressed or uncompressed bundle?

Uncompressed. Cloudflare Workers and Vercel Edge measure the raw bundled JavaScript, not the gzipped or Brotli transit payload. A bundle that compresses to 300 KB can still be rejected if its uncompressed size is over 1 MB.

Why is my bundle large even though I import only one function?

The package is likely CommonJS or lacks "sideEffects": false, so the bundler cannot statically prove the rest is unused and retains the whole module. A default import or a barrel file has the same effect. Use named imports from an ESM build.

What is the single highest-leverage optimization?

Replacing one large transitive dependency—lodash (full), moment, or AWS SDK v2—with a native Web API or a modular edge-compatible package. This routinely removes more weight than minification and tree-shaking combined.

Does Netlify's 20 MB limit mean bundle size does not matter there?

Bundle size still affects cold-start parse time even when the hard cap is generous. The Deno runtime must compile what you ship, so trimming weight reduces initialization latency regardless of the headroom.

How do I find transitive dependencies inflating the bundle?

Generate a metafile during a dry-run build and feed it to a bundle visualizer, or run esbuild --analyze=verbose. Flag any package that pulls in lodash, moment, crypto-browserify, or buffer, then replace the root package.

Does a WASM module or JSON file count toward the bundle limit?

Yes. Cloudflare measures the total size of every module in the deployment, so an imported WASM binary, a bundled JSON dataset, and any inlined asset all consume the same budget as JavaScript. A 700 KB WASM module leaves roughly 300 KB for code no matter how well the code tree-shakes. Source maps are the exception: they are uploaded separately and excluded from the script size measurement.

Why did adding a dynamic import make my bundle bigger?

A dynamic import() with a computed specifier — import('./locales/' + lang + '.js') — forces the bundler to include every file that could match, because it cannot determine the value at build time. Forty locale files become forty chunks in the artifact. Use an explicit map of keys to static imports when the set is small and known, or move the data out of the bundle and fetch it at runtime.

Should I set "sideEffects": false on my own package?

Only if no module in your source tree exists purely for its import-time effect. Registering a global, patching a prototype, or calling an initializer at module scope are all side effects, and a blanket false authorizes the bundler to delete those modules once nothing imports a binding from them. Use the array form — "sideEffects": ["./src/register-globals.ts"] — to keep tree-shaking on everywhere else.

In what order should I apply these techniques?

Reachability before bytes. Replace heavy dependencies first, then fix tree-shaking with sideEffects and named imports, and minify last. A build missing "sideEffects": false shows almost no gain from minification because the minifier is compressing code that should have been removed outright. Confirm the module count in your metafile drops before tuning minifier flags.

Conclusion

Bundle optimization at the edge is non-optional when deploying to Cloudflare Workers or Vercel Edge Middleware. The 1 MB uncompressed limit is a hard rejection threshold, not a soft warning. Beyond compliance, every kilobyte removed directly reduces initialization latency. Start with dependency replacement (the highest-leverage action), enforce tree-shaking via ESM and "sideEffects": false, and gate bundle size in CI before it becomes a production incident.