Tree-Shaking Dependencies for Edge Middleware

This guide is part of Edge Runtime Fundamentals & Platform Constraints. It explains why a bundler that reports “tree-shaking enabled” can still ship you a 1.9 MB middleware, and what to change so the dead code genuinely leaves the artifact.

Tree-shaking is not an optimization the bundler applies to your code. It is a proof the bundler attempts: for every export it wants to delete, it must convince itself that removing that binding cannot change observable behaviour. Every technique below is really a way of making that proof easier. When the proof fails — because a module might have side effects, because the import graph is dynamic, because the module format does not support static analysis — the bundler keeps the code, silently, and your deploy fails at the size limit with no clue as to which dependency caused it.

The problem

You add one small feature to middleware: a feature-flag lookup, or a currency formatter, or an analytics event. The deploy fails. Cloudflare rejects the upload with Script startup exceeded CPU time limit, or Vercel reports Edge Function size exceeded the 1 MB limit, or Netlify’s bundle balloons past what its startup budget tolerates. You look at the diff and it is eleven lines.

The eleven lines imported one function from a package that exports four hundred. You expected the bundler to keep one and drop the rest. It kept all four hundred, plus their transitive dependencies, plus a polyfill layer for the Node APIs those dependencies assume exist.

Root cause: static analysability, not bundler configuration

A bundler can only remove an export when it can prove three things statically: that the module has no side effects at evaluation time, that the import graph is knowable without running the program, and that nothing re-exports the binding in a way that keeps it reachable. Four common patterns break at least one of these proofs.

CommonJS defeats the analysis entirely. module.exports is a runtime object mutation. A CJS module can assign exports conditionally, delete them, or build them in a loop, so a bundler cannot know at build time which properties exist. Its only safe move is to keep the entire module and everything it requires. ESM is different by design: import and export are declarations resolved before evaluation, which is precisely what makes the removal proof possible. One CJS dependency deep in your graph pins its whole subtree into the output.

An undeclared sideEffects field forces a conservative assumption. Importing a module can do work — register a polyfill, patch a prototype, populate a registry. If a package does not tell the bundler otherwise, the bundler must assume every module in it might do that, and so must evaluate it, and so must include it. "sideEffects": false in a package’s package.json is the author asserting “evaluating any file here does nothing observable”, which unlocks removal of unused modules wholesale rather than just unused exports.

Barrel files re-export everything into one node. import { formatMoney } from "@acme/utils" resolves to an index.ts that re-exports fifty modules. If any of those fifty is CJS, or has side effects, or drags in a heavy transitive dependency, you now depend on it — not because you use it, but because you touched the barrel.

The runtime has no shared standard library. In Node, a dependency that reaches for crypto or path costs nothing extra; those modules are already in the process. In an isolate they are not, so a bundler either fails or injects a polyfill, and that polyfill is now bytes in your artifact. This is why an SDK that is a rounding error on a server is fatal at the edge.

Bundle composition before and after The before bar is dominated by a vendor analytics SDK and a date library, with application code a thin sliver. The after bar retains the same application code while the SDK is replaced by a direct fetch call. Before 1.9 MB analytics SDK + polyfills date lib yours After 210 kB same application code, no vendor runtime vendor SDK utility library your middleware direct fetch call
The application code never changed size. Ninety percent of the artifact was code paths no request ever executed.

Step 1 — Measure before you change anything

Every subsequent step is a guess without a byte-level attribution of the current bundle. esbuild’s metafile gives you exactly that: which input contributed how many bytes to which output, after shaking.

# Build the middleware entry the way the platform will, then explain the result.
npx esbuild src/middleware.ts \
  --bundle \
  --format=esm \
  --platform=browser \
  --target=es2022 \
  --minify \
  --conditions=worker,browser \
  --metafile=meta.json \
  --analyze=verbose \
  --outfile=dist/middleware.js

--analyze=verbose prints a ranked list of inputs by contributed bytes. Read the top five lines; in a bloated edge bundle they are almost always a single vendor package and its polyfill shims. Keep meta.json — the CI budget in Step 6 reads it, and diffing two metafiles across a branch tells you exactly which import added weight.

--conditions=worker,browser matters more than it looks. Package exports maps often ship a Node build, a browser build and a worker build of the same module. Resolving the Node condition is how you accidentally pull in a crypto shim that the browser condition would never have referenced.

Step 2 — Declare sideEffects: false in your own packages

If you publish internal packages into your own workspace, they are usually the easiest win, because you control the assertion.

{
  "name": "@acme/edge-utils",
  "type": "module",
  "sideEffects": false,
  "exports": {
    ".": "./dist/index.js",
    "./*": "./dist/*.js"
  }
}

Three things are happening. "type": "module" makes every .js file ESM, so the bundler gets static import declarations. "sideEffects": false promises that importing any file has no observable effect, which lets the bundler drop entire unreferenced modules instead of only unreferenced exports. The wildcard exports subpath makes deep imports legal, which Step 4 depends on.

The promise must be true. If one module in the package calls globalThis.fetch = wrap(fetch) at evaluation time, sideEffects: false will delete it whenever nothing imports a binding from it, and you will get a bug that only reproduces in the production build. When a package has exactly one such module, narrow the claim rather than abandoning it:

{
  "sideEffects": ["./dist/register-polyfills.js", "*.css"]
}

Step 3 — Push every dependency onto the ESM path

A CJS dependency is not shakeable, so the goal is to ensure nothing in the graph resolves to CJS. Two moves cover most cases: choose the ESM condition explicitly at build time, and fail the build when a CJS module sneaks in anyway.

// build.config.ts — a thin wrapper so local builds and CI resolve identically.
export const edgeBuild = {
  entryPoints: ["src/middleware.ts"],
  bundle: true,
  format: "esm" as const,
  platform: "browser" as const,
  target: "es2022",
  // Order matters: the first matching condition in the package's exports map wins.
  conditions: ["worker", "browser", "module", "import"],
  mainFields: ["module", "browser", "main"],
  // Anything reaching for a Node built-in should break the build loudly,
  // not silently pull in a shim that inflates the artifact.
  external: [] as string[],
  metafile: true,
  minify: true,
  legalComments: "none" as const,
};

mainFields ordering is the legacy half of the same decision, used when a package has no exports map. Putting module ahead of main selects the ESM build of older packages that still ship both. Leaving external empty is deliberate: marking node:crypto as external makes the build pass and the deploy fail, which is strictly worse than a build error.

When a dependency only ships CJS and you genuinely need it, you have three options in descending order of preference: find an ESM-native alternative, inline the twenty lines you actually use into your own code, or accept it and confine it behind a dynamic import on a path that rarely executes. Do not reach for an interop plugin — it converts the module but cannot recover the static analysis you lost.

Why a dependency did not shake Three sequential questions test whether a dependency ships an ESM build, declares sideEffects false, and is imported through a barrel file. Each failing answer branches to the reason the bundler kept the code and the corresponding fix. Ships an ESM build? CJS: whole module retained swap or inline it sideEffects: false? every module evaluated patch upstream Imported via a barrel? siblings pulled in too deep-import the file Shakes cleanly yes yes no no no yes
Work the questions top to bottom. The first "no" is almost always the whole explanation for a bundle that will not shrink.

Step 4 — Replace barrel imports with deep imports

A barrel is a convenience for authors and a hazard for bundlers. Even when shaking works, resolving a barrel forces the bundler to parse every re-exported module to determine reachability, which inflates build time; and when shaking does not work, you ship all of them.

// Before: one identifier, fifty modules parsed, unknown number retained.
import { formatMoney } from "@acme/edge-utils";

// After: exactly one module in the graph.
import { formatMoney } from "@acme/edge-utils/format-money";

The same discipline applies to standard-library-shaped dependencies. Importing a country lookup from a package root frequently drags in an ISO dataset you never query. Importing the single submodule does not.

Make the rule mechanical rather than a review habit, using an ESLint restriction on the package root while allowing subpaths:

// eslint.config.ts
export default [
  {
    files: ["src/middleware.ts", "src/edge/**/*.ts"],
    rules: {
      "no-restricted-imports": ["error", {
        paths: [
          { name: "@acme/edge-utils", message: "Deep-import the submodule: @acme/edge-utils/<name>." },
          { name: "lodash", message: "Not edge-safe. Inline the two lines you need." },
        ],
        patterns: [{ group: ["node:*"], message: "Node built-ins are unavailable in the edge runtime." }],
      }],
    },
  },
];
Barrel import versus deep import graph On the left the entry file imports a barrel index which re-exports five modules, all of which enter the bundle. On the right the entry file imports one submodule directly and only that module enters the bundle. Barrel import middleware.ts index.ts format-money iso-countries html-sanitize date-parse crypto-shim 5 modules retained Deep import middleware.ts format-money 1 module retained
The barrel does not merely risk extra bytes — it makes the crypto shim, which nothing in your code calls, a dependency of your middleware.

Step 5 — Delete the SDK and keep the one call

The largest single win is usually not shaking at all. It is noticing that a 900 kB client exists to wrap one HTTP request, and that the request is nine lines of fetch.

Vendor SDKs are built for a Node server: they carry retry frameworks, telemetry, streaming upload helpers, a logger, and a compatibility layer for four runtimes. Middleware needs one endpoint, one payload shape, one timeout.

// lib/track.ts — replaces an entire analytics SDK for the one call middleware makes.
interface TrackEvent {
  name: string;
  userId: string;
  properties?: Record<string, string | number | boolean>;
}

export async function track(
  event: TrackEvent,
  apiKey: string,
  signal?: AbortSignal,
): Promise<boolean> {
  const res = await fetch("https://api.analytics.example.com/v1/events", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      event: event.name,
      user_id: event.userId,
      timestamp: new Date().toISOString(),
      properties: event.properties ?? {},
    }),
    signal,
  });
  // Analytics is never worth failing a request over; report, do not throw.
  return res.ok;
}

The cost of this trade is that you own the wire format. That is a real cost, and it is the right one to pay at the edge, because the alternative is shipping a runtime you cannot audit into an environment measured in kilobytes. Pin the vendor’s API version in the URL, and keep the payload construction in one file so a schema change is a one-file diff. The same reasoning drives the guidance in optimizing bundle size for edge runtime deployment.

Step 6 — Freeze the win with a CI size budget

An optimization without a gate regresses within a month, because the next person to add a feature has no signal that they added 400 kB. Compute the gzipped size of the built artifact and fail the job above a threshold.

#!/usr/bin/env bash
set -euo pipefail

BUDGET_BYTES=250000   # gzipped ceiling for the middleware entry

npx esbuild src/middleware.ts --bundle --format=esm --platform=browser \
  --target=es2022 --minify --conditions=worker,browser \
  --metafile=meta.json --outfile=dist/middleware.js

ACTUAL=$(gzip -c dist/middleware.js | wc -c)
echo "middleware.js gzipped: ${ACTUAL} bytes (budget ${BUDGET_BYTES})"

if [ "$ACTUAL" -gt "$BUDGET_BYTES" ]; then
  echo "Bundle budget exceeded. Top contributors:"
  npx esbuild --analyze=verbose --bundle src/middleware.ts \
    --format=esm --platform=browser --outfile=/dev/null | head -30
  exit 1
fi

Budget the gzipped bytes, because that is what the platform limit and the transfer cost are actually about. Set the initial ceiling roughly ten percent above today’s measured size so the gate catches step changes rather than noise, and lower it deliberately as you reclaim space. Printing the analysis on failure is what makes the gate useful instead of merely annoying — the person who broke it gets the answer in the same log line.

Pick the number from the ceiling your platform actually enforces, not from a general sense of “small”:

Provider Enforced artifact ceiling What the ceiling measures Startup cost signal
Cloudflare Workers 3 MB compressed on paid plans, 1 MB on free The uploaded script plus bundled modules, after compression Startup CPU is capped at roughly 400 ms; module evaluation counts
Vercel Edge Middleware 1 MB for the middleware bundle on Hobby, 4 MB on Pro/Enterprise The built edge function, after compression Larger bundles lengthen isolate boot and show up in TTFB
Netlify Edge Functions 20 MB per function, Deno-based The bundled module graph including imports Boot time grows with graph size; no separate startup CPU cap

Netlify’s ceiling is generous enough that it will not stop you, which is precisely why a self-imposed budget matters there: nothing fails until users feel the boot time. Cloudflare’s compressed limit is the strictest in practice, and its separate startup-CPU cap means a large artifact can be rejected for evaluation time even when it fits under the size limit. See memory and CPU limits across edge providers for the runtime side of the same budget.

Matcher and platform configuration

Shrink the surface as well as the bundle. Middleware that never runs on static assets is middleware whose cost you never pay, which matters because the artifact is evaluated per isolate boot.

// middleware.ts
export const config = {
  runtime: "edge",
  matcher: ["/((?!_next/static|_next/image|favicon.ico|assets/).*)"],
};

On Cloudflare, the equivalent lever is keeping the entry small and the module format modern:

name = "edge-middleware"
main = "dist/middleware.js"
compatibility_date = "2026-01-15"

[build]
command = "npm run build:edge"

[observability]
enabled = true

Local versus production divergence

Behaviour Local dev Edge production
Tree-shaking Usually disabled in dev builds for speed Always on in the production build
Minification Off, so sizes look enormous and are meaningless On, and gzipped before transfer
exports conditions Dev server may resolve development or node Resolves worker/browser
Node built-ins Present in the dev process, so a stray import works Absent; the import fails or drags in a shim
Size limit None; the dev server happily serves 12 MB Hard platform ceiling, enforced at upload
Startup CPU Irrelevant; module evaluation happens once Counted per isolate boot, and capped
Dynamic import() Resolved lazily at request time Often inlined into the single artifact at build

The first and last rows explain most surprises. Local builds skip shaking, so nothing you observe on the dev server predicts the artifact; and dynamic imports that split cleanly in a browser bundle are frequently flattened for edge deployment, so import() is not a reliable escape hatch for a heavy dependency.

Validating with Vitest

Two things are worth asserting automatically: that the replacement for the deleted SDK behaves, and that the module graph does not regress. The first is a normal unit test; the second is a build assertion over the metafile.

// track.test.ts
import { describe, expect, it, vi, afterEach } from "vitest";
import { track } from "./lib/track";
import metafile from "../meta.json";

afterEach(() => vi.unstubAllGlobals());

describe("track", () => {
  it("posts a normalised event payload", async () => {
    const calls: Array<{ url: string; body: unknown }> = [];
    vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
      calls.push({ url, body: JSON.parse(String(init.body)) });
      return new Response(null, { status: 202 });
    });

    const ok = await track(
      { name: "page_view", userId: "u_42", properties: { plan: "pro" } },
      "key_test",
    );

    expect(ok).toBe(true);
    expect(calls).toHaveLength(1);
    expect(calls[0].body).toMatchObject({
      event: "page_view",
      user_id: "u_42",
      properties: { plan: "pro" },
    });
  });

  it("reports failure instead of throwing", async () => {
    vi.stubGlobal("fetch", async () => new Response(null, { status: 500 }));
    await expect(track({ name: "x", userId: "u" }, "k")).resolves.toBe(false);
  });

  it("keeps the module graph free of banned packages", () => {
    const inputs = Object.keys((metafile as { inputs: Record<string, unknown> }).inputs);
    const banned = inputs.filter((p) => /node_modules\/(lodash|moment|axios)\//.test(p));
    expect(banned).toEqual([]);
  });
});

The third test is the one that keeps working after everyone forgets this page exists. A graph assertion fails on the pull request that introduces the dependency, which is the only moment the fix is cheap. If you prefer to keep test code free of build artifacts, move the same check into the CI script from Step 6 and read meta.json with jq.

Common pitfalls and resolutions

Bundle unchanged after adding sideEffects: false — the field belongs in the dependency’s package.json, not yours, unless you own the dependency. Adding it to your application root does nothing for third-party code.

Removing an import made the app break in production only — a module you dropped was doing registration work at evaluation time. The sideEffects: false claim on that package is wrong; narrow it to an array listing the effectful files.

Deep import fails with ERR_PACKAGE_PATH_NOT_EXPORTED — the package’s exports map has no subpath entry. You cannot deep-import it; either it exposes what you need at the root or it does not.

Size dropped locally but the deploy is unchanged — the platform build uses a different config than your local esbuild invocation. Make the platform build call the same script, or the measurement is fiction.

A node:crypto shim appears in the analysis — a dependency resolved its Node condition. Reorder conditions to put worker first and re-run the analysis to confirm it left the graph, then see polyfill strategies for Node.js APIs at the edge.

Build time doubled after adding one import — you imported a barrel that re-exports several hundred modules. The parse cost is real even when nothing is retained.

Production deployment checklist

Frequently Asked Questions

Why does a CommonJS dependency defeat tree-shaking?

CommonJS builds its exports by mutating a runtime object, so a bundler cannot know at build time which properties exist or whether reading one has an effect. Without that knowledge it cannot prove a removal is safe, so it keeps the entire module and everything it requires. ESM imports and exports are static declarations, which is what makes the removal proof possible.

What does sideEffects false actually promise?

It promises that merely evaluating any module in the package produces no observable effect, so the bundler may delete whole modules nothing imports from, rather than only deleting unused exports. If one file patches a global or registers something at import time the claim is false, and the bundler will remove code you depend on. In that case list the effectful files in an array instead of claiming false for everything.

Are deep imports worth the uglier import paths?

At the edge, yes. A barrel forces the bundler to parse every re-exported module and, whenever shaking is imperfect, ships them. A deep import puts exactly one module in the graph, which both shrinks the artifact and cuts build time. Enforce it with a lint rule so it does not depend on code review.

When should I replace a vendor SDK with a plain fetch?

When middleware uses one or two endpoints of it. SDKs carry retry frameworks, telemetry, loggers and multi-runtime compatibility layers that a request-path function does not need, and those are the bulk of the bytes. You take on owning the wire format, which is a real cost, but it is smaller than shipping an unauditable runtime into a kilobyte-budgeted environment.

Should the CI budget measure raw or gzipped bytes?

Gzipped, because that is what the transfer cost and most platform accounting reflect. Set the initial ceiling about ten percent above the current measured size so the gate catches step changes rather than noise, then lower it deliberately as you reclaim space.

Why does my local build look much larger than production?

Development builds usually skip both minification and tree-shaking to keep rebuilds fast, and the dev server may resolve development or node export conditions instead of worker. Nothing you observe on the dev server predicts the deployed artifact, so always measure the production build configuration.