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.
esbuildavailable (npm i -D esbuild) for compiling and tree-shaking real application JavaScript.duand Lighthouse locally so you can measure the output directory and Total Blocking Time before and after.
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.
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:
| Stage | JS shipped per page | Total Blocking Time | Lighthouse perf |
|---|---|---|---|
Baseline (global app.js) | 78 KB | 320 ms | 79 |
| After scoping with bundle plugin | 31 KB | 180 ms | 88 |
| After esbuild tree-shake + minify | 19 KB | 120 ms | 93 |
| After deferring third-party | 11 KB | 60 ms | 97 |
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.
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": falseinpackage.jsonwhere accurate so bundlers can drop more. - Dev settings in production: unminified output and sourcemaps shipped live. Gate minification on
ELEVENTY_ENV=productionin CI. - Rollback: scoping and bundling live in
eleventy.config.jsand template front matter, all version-controlled, so reverting is agit revertand a rebuild — there is no runtime state and no cache to untangle.
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.
Related
- Parent: JavaScript Hydration & Partial Rendering — the opt-in JavaScript principle this applies to Eleventy.
- Astro Islands vs Full Hydration Performance — the same trade-off with Astro's directive system.
- Measuring INP on Static Sites with Real-User Monitoring — confirming the trimmed payload improves field INP.
- Performance Optimization & Core Web Vitals for SSGs — where JavaScript budgets fit the INP picture.