How to Reduce Bundle Size in Eleventy Builds

Eleventy doesn't bundle JavaScript for you — it's a templating tool — so bloat usually comes from scripts dropped into a base layout that every page then downloads, parses, and executes. The fixes are three: scope scripts to the pages that actually need them, run them through a real bundler that tree-shakes (esbuild), and defer or lazy-load third-party code. This applies the JavaScript Hydration & Partial Rendering patterns from Performance Optimization & Core Web Vitals for SSGs to Eleventy specifically, where there is no client:* directive but the same opt-in principle applies.

Prerequisites

  • An Eleventy site (3.x recommended — the bundle plugin ships with core) that currently loads one or more scripts from a shared layout.
  • esbuild available (npm i -D esbuild) for compiling and tree-shaking real application JavaScript.
  • du and Lighthouse locally so you can measure the output directory and Total Blocking Time before and after.
Trimming an Eleventy JavaScript bundle A 78 kilobyte source of scripts flows through three trimming stages — scoping per route, tree-shaking and minifying with esbuild, and deferring third-party code — producing an 11 kilobyte shipped bundle. From a 78 KB global script to an 11 KB per-route payload Source JS 78 KB global Scope bundle plugin per route esbuild tree-shake + minify Defer third-party lazy-load Shipped 11 KB Each stage removes code the page never needed; the browser only ever sees the trimmed result.
Scoping removes code from pages that don't use it, esbuild drops unused exports and whitespace, and deferring keeps third-party code off the critical path — 78 KB down to 11 KB.

Find the Bloat

Build, then measure the output directory to find the largest scripts:

npx @11ty/eleventy
find _site -type f -name '*.js' -exec du -h {} + | sort -rh | head

Cross-reference with a Lighthouse run to spot duplicated polyfills and oversized vendor code. On the site used for the numbers below, a single app.js in the base layout was 78 KB minified and shipped on all 1,200 pages, even though only three pages used the chart code inside it.

Scope Assets with the Bundle Plugin

Eleventy's bundle plugin (bundled with Eleventy 3.x; install it explicitly on older versions) provides {% js %}/{% css %} shortcodes that collect only the scripts a page actually uses, instead of a global <script> in the layout. Minification is applied through a transform, not an option flag:

// eleventy.config.js
const { EleventyBundlePlugin } = require("@11ty/eleventy/src/Plugins/BundlePlugin.js");
const esbuild = require("esbuild");

module.exports = function (eleventyConfig) {
  eleventyConfig.addBundle("js", {
    transforms: [
      async function (content) {
        if (process.env.ELEVENTY_ENV !== "production") return content;
        const { code } = await esbuild.transform(content, { minify: true });
        return code;
      },
    ],
  });
};

In templates, wrap {% js %} blocks so they only emit on pages that need them (drive it from front matter), keeping per-route payloads minimal. Moving the chart code out of the global layout and into a scoped block took it off 1,197 pages immediately.

Bundle and Tree-Shake with esbuild

For real application JavaScript, compile with esbuild — it tree-shakes and minifies in one pass:

npx esbuild src/scripts/index.js \
  --bundle --minify --tree-shaking=true \
  --target=es2020 \
  --metafile=meta.json \
  --outfile=_site/assets/bundle.js

--metafile=meta.json writes an analysis you can inspect to see what each module contributes — paste it into esbuild's online analyzer or read it directly. Reference the output with <script src="/assets/bundle.js" defer></script> and add the source to Eleventy's passthrough copy if needed. Setting "sideEffects": false in package.json where it is accurate lets esbuild drop even more dead code. Because this esbuild step runs outside Eleventy's own template pipeline, wire it into your build command (or CI) alongside Eleventy's incremental builds so a script change doesn't trigger a full rebuild every time.

Defer Third-Party Scripts

Third-party code is often the largest, least-controlled payload, and it runs on the same main thread your interactions need. Add defer to analytics and widgets, self-host critical fonts to avoid an extra DNS lookup and render-blocking request, and load heavy widgets via an Intersection Observer only when they scroll into view. Gate optional scripts on a front-matter flag:

layout: default
needsChart: true

Then {% if needsChart %}…{% endif %} around the relevant {% js %} block so the chart code never ships on pages that don't use it.

Measured Impact

The same documentation site, before and after applying all three steps, measured with du on _site and Lighthouse on a throttled mid-tier mobile profile:

StageJS shipped per pageTotal Blocking TimeLighthouse perf
Baseline (global app.js)78 KB320 ms79
After scoping with bundle plugin31 KB180 ms88
After esbuild tree-shake + minify19 KB120 ms93
After deferring third-party11 KB60 ms97

The largest single win was scoping — taking the chart code off the 1,197 pages that never used it more than halved the typical page's JavaScript on its own. Tree-shaking removed a duplicated date-formatting library and unused exports, and deferring the analytics tag took it off the critical path entirely.

JavaScript, blocking time and Lighthouse score across four stages Four stacked bars show JavaScript shipped per page falling from 78 kilobytes at baseline, to 31 after scoping with the bundle plugin, to 19 after esbuild tree-shaking and minifying, to 11 after deferring third-party code. Total Blocking Time falls alongside from 320 to 180 to 120 to 60 milliseconds, and the Lighthouse performance score climbs from 79 to 88 to 93 to 97. Each stage trims JavaScript, blocking time and score together Bar length = JavaScript shipped per page (measured with du on _site) Baseline global app.js 78 KB TBT 320 ms Lighthouse 79 Bundle plugin scoped per route 31 KB TBT 180 ms Lighthouse 88 esbuild tree-shake + minify 19 KB TBT 120 ms Lighthouse 93 Defer 3rd-party off critical path 11 KB TBT 60 ms Lighthouse 97 Scoping is the single biggest drop; every later stage keeps trimming payload and main-thread time in step.
The same four stages as the table: scoping more than halves per-page JavaScript, then esbuild and deferring keep both the payload and Total Blocking Time falling — 78 KB / 320 ms down to 11 KB / 60 ms, Lighthouse 79 to 97.

Pitfalls & Rollback

  • Global <script> in the layout: every route then downloads it. Use scoped {% js %} blocks or front-matter conditionals.
  • No tree-shaking: unused exports inflate the bundle. esbuild tree-shakes by default; set "sideEffects": false in package.json where accurate so bundlers can drop more.
  • Dev settings in production: unminified output and sourcemaps shipped live. Gate minification on ELEVENTY_ENV=production in CI.
  • Rollback: scoping and bundling live in eleventy.config.js and template front matter, all version-controlled, so reverting is a git revert and a rebuild — there is no runtime state and no cache to untangle.
The order that finds the bytes fastest Four steps: measure what is actually shipped, delete unused dependencies, scope the remaining scripts per page, and bundle and tree-shake the rest. The order that finds the bytes fastest Measure what ships per page Delete unused dependencies Scope per-page bundles Tree-shake esbuild the rest A dependency imported once for a helper function is the most common single win.
The second step usually returns more than the fourth, and takes minutes rather than an afternoon.

What Eleventy Ships by Default, and What You Added

Eleventy is unusual among modern generators in that it ships no JavaScript to the browser at all unless you ask it to. Every kilobyte in the bundle is therefore something the site chose, which makes the audit unusually direct: list what is loaded, and for each entry name the feature it provides and the page it is needed on.

That list typically contains four kinds of thing. A syntax highlighter that could have run at build time instead. A date or utility library imported for one function. A component framework added for a single widget. And third-party embeds, which are usually larger than everything else combined and belong in the loading tiers rather than the bundle.

Deal with them in that order. Moving highlighting to build time removes a dependency and improves the first paint at once; replacing a utility import with a five-line function removes another; and the framework question is usually answered by asking whether one widget justifies shipping a runtime to every page. Only after those three is bundling and tree-shaking worth the configuration, because by then there is much less left to shake.

Set a budget once the bundle is small, or it will not stay small. A check that fails the build when the JavaScript shipped by any page exceeds a stated figure — 30 kilobytes is generous for a documentation site — turns bundle size from a periodic clean-up into a property the pipeline maintains. The number matters less than having one, because the failure mode without it is not a sudden regression but a slow accumulation that nobody owns.

One last check before declaring the work finished: load a page with JavaScript disabled. On a documentation site every page should still be readable and navigable, and anything that breaks is a component that should have been progressive enhancement rather than a requirement. That test takes five seconds and reliably finds the one widget that quietly became load-bearing.

Conclusion

Treat Eleventy JavaScript as opt-in per page: scope it with the bundle plugin, compile and tree-shake real application code with esbuild, and defer or lazy-load third-party scripts. Measure _site before and after with du and a Lighthouse run, and the initial payload stays small as the site grows — here, 78 KB to 11 KB and a Total Blocking Time five times lower.

FAQ

Does Eleventy bundle JavaScript natively?

No. Eleventy is a templating tool and does not process JavaScript on its own. Use the Eleventy bundle plugin to scope scripts per page and esbuild or Rollup to compile, tree-shake, and minify the application code those pages reference.

How do I verify the reduction?

Measure the output directory before and after with du, inspect esbuild's --metafile to see what each module contributes, and confirm the field gains with a Lighthouse run. Watching Total Blocking Time fall in Lighthouse is the quickest signal that the trimmed payload reached the browser.

Can I load heavy scripts only on certain pages?

Yes. Set a front-matter flag such as needsChart: true and wrap the relevant bundle shortcode in a conditional so the script ships only on the pages that set the flag. Everything else stays script-free.

Why is a single global script in the layout so costly?

Because every route that extends that layout downloads, parses, and executes it, even pages that never use the feature. On a large site that one script multiplies across thousands of pages and shows up as wasted main-thread time and a worse INP everywhere.