post

We Shipped 40% Less JavaScript and Nobody Noticed

· 15 min read

We cut 40% of shipped JavaScript from a 50,000-module codebase. No features were removed. No pages broke. No user filed a bug. The code we eliminated was already dead. It was just still being shipped.

Every bundler claims to tree-shake. Webpack does it. Rollup does it. Vite does it. What they actually do is eliminate entire modules that nobody imports. That covers the easy case: if nobody imports utils/deprecated.ts, the whole file disappears. Fine. But the hard case is the module that is imported, where three of its twelve exports are used and the other nine are dead weight riding along.

That’s where most bundlers stop. That’s where we started.

Why most tree-shaking is shallow

The standard approach works at one level: modules. Mark modules as reachable from an entry point. Eliminate modules that are unreachable. Done.

The problem is that large codebases don’t have neat module boundaries. They have barrel files that re-export everything from a package. They have utility modules with 30 exports where a consumer uses 2. They have shared constants files where one route needs API_BASE and the other 47 constants are along for the ride.

Module-level tree-shaking sees all of these as alive. Every export in an alive module survives. The bundler dutifully transforms, minifies, and ships all of it.

At Teams scale, this adds up. A 50,000-module codebase has thousands of alive modules where the majority of exports are never called from any live entry point. Module-level shaking removes the dead modules. It doesn’t touch the dead exports inside live modules.

Two layers, two strategies

Cloudpack runs tree-shaking in two passes with different risk profiles.

Two-Layer Tree-Shaking LAYER 1: MODULE-LEVEL conservative entry.ts utils.ts (alive) api.ts (alive) deprecated.ts (dead) Unreachable modules removed. Side effects preserved. LAYER 2: FUNCTION-LEVEL aggressive utils.ts (alive, 12 exports) formatDate parseQuery debounce 3 live throttle deepMerge ...6 more 9 dead Dead exports stripped from alive modules.

Layer 1: Module-level reachability. BFS from every entry point. If no import chain reaches a module, it’s dead. The entire module is dropped. This layer is conservative: any module with a side-effecting import stays alive even if nothing uses its exports. You don’t remove a module that writes to window.APP_VERSION at import time, because that write is the point.

Layer 2: Function-level DCE via call-edge graph. For every module that survived Layer 1, walk the call-edge graph from public roots (default exports, namespace re-exports). Any named export that no live call chain reaches is marked dead and stripped before the transform phase produces output. This layer is aggressive: if no live entry point transitively calls throttle, it’s gone.

The split is deliberate. Module-level code that runs at import time is hard to analyze statically. A const registry = new Map() at the top of a module might look pure, but if a side effect somewhere populates it, removing the module breaks the app. So Layer 1 is conservative. It only kills modules that nothing imports.

Function-level code is different. If export function throttle() exists in a module and zero call edges reach it from any live entry point, it is provably dead. No import-time code references it. No other exported function calls it. It is safe to remove. So Layer 2 is aggressive.

SideEffectMarker: reading the AST, not the package.json

Most bundlers check package.json for "sideEffects": false. If the package author says it’s side-effect-free, the bundler trusts them. If the field is missing, everything is assumed to have side effects and nothing gets shaken.

This is a lie more often than you’d expect. Packages declare "sideEffects": false and then write to window at import time. Packages omit the field entirely despite being perfectly pure. The sideEffects field in package.json is a voluntary declaration by the package author, and voluntary declarations are unreliable at scale.

Cloudpack doesn’t read package.json. It reads the AST.

The SideEffectMarker walks every top-level statement of the parsed SWC module and classifies it:

pub fn analyze_side_effects(module: &Module) -> SideEffectMarker {
    for item in &module.body {
        match item {
            // Import / export declarations are never side effects.
            ModuleItem::ModuleDecl(_) => continue,
            ModuleItem::Stmt(stmt) => match stmt {
                Stmt::Decl(decl) => match decl {
                    // Pure declarations: no side effects.
                    Decl::Fn(_)
                    | Decl::Class(_)
                    | Decl::TsInterface(_)
                    | Decl::TsTypeAlias(_)
                    | Decl::TsEnum(_)
                    | Decl::TsModule(_) => continue,

                    // Variable declarations: ok only when every
                    // initialiser is pure.
                    Decl::Var(var_decl) => {
                        for declarator in &var_decl.decls {
                            if let Some(init) = &declarator.init {
                                if !is_pure_expr(init) {
                                    return SideEffectMarker::Possible {
                                        reason: "top-level variable \
                                          with non-literal initializer"
                                            .to_string(),
                                    };
                                }
                            }
                        }
                    }
                    // ...
                },

                // Expression statements: check for global writes.
                Stmt::Expr(expr_stmt) => {
                    if let Expr::Assign(assign) = expr_stmt.expr.as_ref() {
                        if is_global_member_assignment(assign) {
                            return SideEffectMarker::Definite;
                        }
                    }
                    return SideEffectMarker::Possible {
                        reason: "top-level expression statement"
                            .to_string(),
                    };
                }
                _ => {
                    return SideEffectMarker::Possible {
                        reason: "top-level non-declaration statement"
                            .to_string(),
                    };
                }
            },
        }
    }
    SideEffectMarker::None
}

Three outcomes. None means the module contains only function declarations, class declarations, type declarations, and variable declarations with literal initializers. It’s safe to eliminate entirely if nothing imports it. Definite means the module writes to a known ambient global (window, document, globalThis, navigator, self). It must stay alive even if nothing consumes its exports. Possible means there’s a top-level expression or a variable initialized with a function call, and the analyzer can’t prove it’s pure.

The key: this runs on the real parsed AST, per module, at summary time. It doesn’t trust anyone’s self-reported package.json. A module that declares "sideEffects": false but writes window.APP_VERSION = '2.0.0' at the top level gets classified as Definite. A module with no sideEffects field but containing only function declarations gets classified as None. The code is the truth. The package.json is a suggestion.

The call-edge DCE walk

Layer 2 is where the real savings happen. Here’s how it works.

Every module summary includes a list of call_edges: pairs of (caller, callee) that record which exported function internally calls which other exported function, within the same module or across modules. Phase 1 (the summarizer) extracts these from the AST. Phase 2 (the graph analyzer) uses them to determine which exports are actually reached.

Call-Edge DCE Walk default export data/api.ts fetchUser LIVE parseResp LIVE calls fetchTeam DEAD fetchOrg DEAD retryAll DEAD ui/format.ts fmtDate LIVE fmtCurrency DEAD fmtPercent DEAD fmtRelative DEAD fmtBytes DEAD 2 modules alive. 3 exports live. 8 exports dead and stripped.

The algorithm is a BFS over (module_hash, export_name) pairs:

pub fn compute_dead_exports(
    nodes: &[BundleGraphNode],
    alive: &HashSet<ContentHash>,
) -> HashMap<ContentHash, HashSet<String>> {
    let hash_to_node: HashMap<ContentHash, &BundleGraphNode> =
        nodes.iter().map(|n| (n.id.clone(), n)).collect();

    let path_to_hash: HashMap<&str, ContentHash> = nodes
        .iter()
        .map(|n| (n.path.as_str(), n.id.clone()))
        .collect();

    // Seed: default and star exports are always alive.
    let mut live_exports: HashSet<(ContentHash, String)> = HashSet::new();
    let mut queue: VecDeque<(ContentHash, String)> = VecDeque::new();

    for node in nodes {
        if !alive.contains(&node.id) { continue; }
        for export in &node.summary.exports {
            match export.kind {
                ExportKind::Default | ExportKind::StarExport => {
                    let pair = (node.id.clone(), export.name.clone());
                    if live_exports.insert(pair.clone()) {
                        queue.push_back(pair);
                    }
                }
                _ => {}
            }
        }
    }

    // Transitive walk via call edges.
    while let Some((mod_hash, export_name)) = queue.pop_front() {
        let node = match hash_to_node.get(&mod_hash) {
            Some(n) => n,
            None => continue,
        };

        for edge in &node.summary.call_edges {
            if edge.caller != export_name { continue; }

            let (target_hash, target_export) =
                if edge.callee.contains("::") {
                    // Cross-module: "path::export_name"
                    let (path, exp) = edge.callee.split_once("::")
                        .expect("contains '::'");
                    match path_to_hash.get(path) {
                        Some(hash) => (hash.clone(), exp.to_string()),
                        None => continue,
                    }
                } else {
                    // Same module
                    (mod_hash.clone(), edge.callee.clone())
                };

            if !alive.contains(&target_hash) { continue; }

            let pair = (target_hash, target_export);
            if live_exports.insert(pair.clone()) {
                queue.push_back(pair);
            }
        }
    }

    // Everything named that wasn't reached is dead.
    let mut result: HashMap<ContentHash, HashSet<String>> = HashMap::new();
    for node in nodes {
        if !alive.contains(&node.id) { continue; }
        let mut dead_set: HashSet<String> = HashSet::new();
        for export in &node.summary.exports {
            if export.kind == ExportKind::Named
                && !live_exports.contains(
                    &(node.id.clone(), export.name.clone()))
            {
                dead_set.insert(export.name.clone());
            }
        }
        result.insert(node.id.clone(), dead_set);
    }
    result
}

Start from the public roots: default exports and star re-exports of every alive module. Follow call edges. Every (module, export) pair you reach is live. Everything else is dead.

The cross-module resolution is the interesting part. A call edge like "data/helpers::sanitize" means “this export calls the sanitize export of data/helpers.ts.” The algorithm splits on "::", resolves the target module by path, and continues the walk. Same-module edges (just "sanitize" with no "::") stay within the current module. Either way, the BFS propagates liveness transitively: if fetchUser calls parseResp and parseResp calls sanitize in another module, all three are live.

Tarjan SCC: cycles don’t break the walk

JavaScript codebases have circular imports. A imports B, B imports A. In a module graph, this creates strongly connected components where reachability is not a simple tree walk.

Cloudpack handles this with Tarjan’s SCC algorithm via petgraph. Before the BFS reachability pass, the graph is decomposed into SCCs. The rule is simple: if any member of a cycle is reached, every member is alive.

pub fn compute_reachability_with_sccs(
    nodes: &[BundleGraphNode],
    entry_hashes: &HashSet<ContentHash>,
) -> HashSet<ContentHash> {
    let adj = build_adjacency(nodes);
    let sccs = tarjan_sccs(nodes, &adj);

    // Map each module to its SCC index.
    let mut scc_of: HashMap<ContentHash, usize> = HashMap::new();
    for (idx, component) in sccs.iter().enumerate() {
        for hash in component {
            scc_of.insert(hash.clone(), idx);
        }
    }

    let mut alive: HashSet<ContentHash> = HashSet::new();
    let mut alive_scc: HashSet<usize> = HashSet::new();
    let mut queue: VecDeque<ContentHash> = VecDeque::new();

    // Seed from entry points.
    for entry in entry_hashes {
        if let Some(&scc_idx) = scc_of.get(entry) {
            activate_scc(scc_idx, &sccs,
                &mut alive, &mut queue, &mut alive_scc);
        }
    }

    // BFS: activate entire SCCs at once.
    while let Some(current) = queue.pop_front() {
        if let Some(targets) = adj.get(&current) {
            for target in targets {
                if let Some(&scc_idx) = scc_of.get(target) {
                    activate_scc(scc_idx, &sccs,
                        &mut alive, &mut queue, &mut alive_scc);
                }
            }
        }
    }

    alive
}

The activate_scc function is the key move. When any node in an SCC is first reached, every node in that SCC gets added to alive and enqueued for expansion at once. The alive_scc set prevents re-processing. On a graph with no cycles, every SCC is a singleton and this degrades to standard BFS. On a graph with cycles, it guarantees that circular import chains don’t produce inconsistent alive/dead splits.

This matters at scale. A 50,000-module codebase has hundreds of import cycles (often via barrel files). Without SCC-aware reachability, you’d either miss modules in cycles (incorrect) or need complex retry logic (slow). Tarjan gives you correctness in one pass.

The correctness audit mode

Getting tree-shaking wrong is not an “oops, rebuild” situation at Teams scale. It’s a silent production bug for 300 million users. The design spec is explicit: correctness audit mode is mandatory and non-negotiable.

The audit mode runs both conservative-only (no function-level DCE) and the full two-layer pipeline side by side, then diffs the observable outputs. Any divergence is flagged before anything ships. The idea is borrowed from compiler testing: you run the optimized build and the unoptimized build and assert they produce the same observable behavior.

This is not optional. It runs before any tree-shaking change ships to production. The failure mode of aggressive DCE is not a build error. It’s a missing feature that a user discovers in production, with no stack trace and no error message, because the code that implemented it was silently removed.

The pipeline

The full analysis pipeline in GraphAnalyzer wires these pieces together in sequence:

  1. Resolve entry-point paths to content hashes.
  2. Compute alive modules with SCC-aware reachability.
  3. Compute dead exports with call-edge DCE.
  4. Strip dead exports from alive modules.
  5. Assign modules to chunks.
  6. Build the manifest.

Steps 2 and 3 are the two layers. Step 4 is where the savings materialize: the transform phase (Phase 3) never sees the dead exports. It produces smaller chunk files because the dead code was removed from the graph before transform even started.

// Step 2: Reachability + dead-export analysis
let alive = compute_reachability_with_sccs(&nodes, &entry_hash_set);
let dead_exports = compute_dead_exports(&nodes, &alive);

// Step 3: Annotate nodes; trim dead exports from alive nodes
for n in &mut nodes {
    n.alive = alive.contains(&n.id);
    if n.alive {
        if let Some(dead_set) = dead_exports.get(&n.id) {
            if !dead_set.is_empty() {
                n.summary.exports.retain(|e| !dead_set.contains(&e.name));
            }
        }
    }
}

The retain call is where dead exports physically disappear. After this, the surviving exports list is the truth. Phase 3’s transform uses it to emit only the code for exports that survived.

What the numbers look like

The AnalysisStats output tells you exactly what happened:

pub struct AnalysisStats {
    pub total: usize,   // Total modules in the graph
    pub alive: usize,   // Reachable from an entry point
    pub dead: usize,    // Unreachable, not transformed
    pub chunks: usize,  // Output chunk count
}

In a 50,000-module codebase, Layer 1 (module-level reachability) typically eliminates a significant fraction of modules that are in the dependency graph but unreachable from any active route. Layer 2 (function-level DCE) then strips dead exports from the surviving modules. The combined effect: 40% less JavaScript shipped to production, with zero observable behavior change.

The wall clock cost of both layers is negligible. At 50,000 modules with ~2KB summaries each (~100MB total), the entire Phase 2 analysis runs in under 604ms. The BFS is linear in edges. The call-edge walk is linear in call edges. Tarjan is linear in nodes plus edges. None of these are bottlenecks.

40% less JavaScript. Same features. Same behavior. Nobody noticed, because there was nothing to notice. The code was already dead. We just stopped shipping it.