LCP Eleventy 2.x / 11ty

Fix LCP in Eleventy: Optimize the Largest Contentful Paint on 11ty Sites

Eleventy starts with a structural advantage over every JavaScript framework: it ships zero client-side runtime by default. No hydration, no React bundle, no framework bootstrap code. That head start should translate directly into fast Largest Contentful Paint scores. Yet many Eleventy sites still report LCP above 3 seconds because they serve unoptimized images, load stylesheets synchronously, and skip the edge-caching configuration that makes static sites genuinely fast at global scale. This guide closes those gaps. By combining @11ty/eleventy-img for responsive AVIF and WebP generation, @11ty/eleventy-plugin-bundle for inlined critical CSS, an HTML minification transform, PurgeCSS, and a properly configured _headers file on Cloudflare Pages or Netlify, you can cut LCP from over 4 seconds to under 1.6 seconds without changing your content architecture.

TL;DR -- Quick wins:
  • Add fetchpriority="high" and loading="eager" to your hero image shortcode call.
  • Configure eleventy-img to output AVIF + WebP at widths 400, 800, 1200, and 1600.
  • Inline above-the-fold CSS with eleventy-plugin-bundle to eliminate render-blocking stylesheets.
  • Add an addTransform using html-minifier-terser to strip HTML whitespace.
  • Deploy on Cloudflare Pages or Netlify and set long-lived Cache-Control headers on hashed assets.

Expected results

The following improvements are representative of a typical Eleventy blog or marketing site after applying all six steps in this guide. Results are measured with Lighthouse 12 on a simulated mid-range Android device on a 4G connection, averaged over five runs.

Before

4.8s

LCP (Poor) -- unoptimized JPEG hero, render-blocking full stylesheet, no edge caching, no resource hints

After

1.4s

LCP (Good) -- AVIF hero with fetchpriority=high, inlined critical CSS, minified HTML, Cloudflare Pages CDN

TTFB also drops from around 380ms (origin server) to under 80ms once HTML is cached at a CDN edge node. TTFB is the starting gun for LCP -- every millisecond you save there is a millisecond saved on the LCP clock.

Common causes of poor LCP in Eleventy sites

Before optimizing, it is worth understanding why an Eleventy site -- which should be fast by construction -- ends up with poor LCP scores. The root causes fall into four categories:

  • Unoptimized hero images. The most common cause. A JPEG or PNG hero served at its original resolution (often 3000px wide, 800KB+) forces the browser to download a massive file before it can complete the LCP paint. No framework magic can fix a 2MB image. Eleventy's eleventy-img plugin solves this by generating AVIF and WebP versions at multiple widths at build time, but the plugin is not installed by default -- you have to add it.
  • Missing fetchpriority on the above-the-fold image. Even after switching to AVIF, if the browser does not know the image is the LCP candidate, it will discover it late in the waterfall. The fetchpriority="high" attribute on the img tag signals to the browser's preload scanner that this resource should jump the download queue.
  • Render-blocking full stylesheets. A common Eleventy pattern is to link a single compiled CSS file in the <head>. On a cold cache, the browser must download and parse the entire stylesheet before it can paint anything. If that stylesheet is 40KB (typical for Tailwind with no purging), you lose 200-400ms on mobile 4G before the first pixel appears.
  • No edge caching or misconfigured cache headers. Eleventy outputs static files, which are ideal for CDN distribution. But if your hosting configuration omits Cache-Control headers or uses short TTLs, every visitor fetches HTML and assets from origin rather than from a nearby edge node. For a globally distributed audience, this can add 300-600ms of pure network latency to LCP.
  • Unnecessary third-party scripts in the document head. Analytics scripts, consent banners, and A/B testing snippets placed in <head> without defer or async block HTML parsing. Even a 10KB script from a fast CDN adds a round-trip that delays the LCP candidate's discovery.

Step-by-step fix

Install and configure eleventy-img for AVIF and WebP

The @11ty/eleventy-img plugin processes images at build time and outputs multiple formats and widths. It generates a <picture> element with <source> tags for AVIF and WebP, with a JPEG fallback for legacy browsers. Start by installing the package and wiring it up as an async Nunjucks shortcode in your .eleventy.js config.

Shell -- install
npm install @11ty/eleventy-img
JavaScript -- .eleventy.js
const Image = require("@11ty/eleventy-img");
const path = require("path");

async function imageShortcode(src, alt, sizes = "100vw", options = {}) {
  const {
    fetchpriority = "auto",
    loading = "lazy",
    decoding = "async",
    widths = [400, 800, 1200, 1600],
    classes = "",
  } = options;

  const imageSrc = src.startsWith("/")
    ? path.join("src", src)
    : src;

  const metadata = await Image(imageSrc, {
    widths,
    formats: ["avif", "webp", "jpeg"],
    outputDir: "./_site/img/",
    urlPath: "/img/",
    filenameFormat: function (id, src, width, format) {
      const ext = path.extname(src);
      const name = path.basename(src, ext);
      return `${name}-${width}w.${format}`;
    },
    sharpAvifOptions: { quality: 60 },
    sharpWebpOptions: { quality: 75 },
    sharpJpegOptions: { quality: 80 },
  });

  const imageAttributes = {
    alt,
    sizes,
    loading,
    decoding,
    class: classes,
    ...(fetchpriority !== "auto" && { fetchpriority }),
  };

  return Image.generateHTML(metadata, imageAttributes);
}

module.exports = function (eleventyConfig) {
  eleventyConfig.addNunjucksAsyncShortcode("image", imageShortcode);
  eleventyConfig.addLiquidTag("image", imageShortcode);
  // ... rest of config
};

This configuration generates AVIF, WebP, and JPEG variants at four widths. AVIF at quality 60 typically achieves 60-70% smaller file sizes than JPEG at quality 80, which is the single largest contributor to faster LCP. The filenameFormat function produces stable, predictable filenames like hero-1200w.avif that remain constant across builds, enabling long-lived CDN caching. For more on the broader strategy here, see the responsive images LCP guide.

Apply fetchpriority=high to the hero image shortcode

Once eleventy-img is generating your responsive images, you need to tell the browser which image is the LCP candidate. Without fetchpriority="high", the browser assigns all images equal network priority and may start downloading the hero image after several other lower-priority requests have already begun. This single attribute change is often worth 200-500ms on LCP.

Critical detail: Set fetchpriority="high" on exactly one image per page -- the LCP candidate. Setting it on multiple images dilutes the priority hint and can actually hurt performance by forcing the browser to fetch several images at high priority simultaneously.
Nunjucks -- hero image (above the fold)
{# Hero image: fetchpriority=high, loading=eager for LCP candidate #}
{% image
  "/img/hero.jpg",
  "A detailed description of the hero image content",
  "(min-width: 1200px) 1200px, (min-width: 800px) 800px, 100vw",
  {
    fetchpriority: "high",
    loading: "eager",
    decoding: "async",
    widths: [400, 800, 1200, 1600],
    classes: "hero__image"
  }
%}
Nunjucks -- below-fold images (lazy load)
{# All other images: lazy by default #}
{% image
  "/img/article-thumbnail.jpg",
  "Article thumbnail description",
  "(min-width: 800px) 400px, 100vw",
  {
    loading: "lazy",
    decoding: "async",
    widths: [200, 400, 800]
  }
%}

The decoding="async" attribute on every image -- including the hero -- tells the browser it can decode the image off the main thread, reducing jank during page load. loading="eager" on the hero ensures it is never lazy-loaded even if a browser plugin or future spec changes the default behavior.

If you are using Liquid templates instead of Nunjucks, the shortcode syntax differs slightly. In Eleventy 2.0+ you register the shortcode with addLiquidTag (as shown in step 1) and call it as {% image "/img/hero.jpg", "alt text", "100vw", { fetchpriority: "high" } %}.

Inline critical CSS with eleventy-plugin-bundle

A render-blocking stylesheet is the most common cause of LCP delays that are not image-related. When the browser encounters a <link rel="stylesheet"> tag in the <head>, it stops HTML parsing, downloads the stylesheet, and parses it before rendering anything. On mobile 4G, a 40KB stylesheet can add 350ms to TTFB-to-first-paint. The solution is to inline the critical above-the-fold CSS directly into <style> tags in the <head> and load the full stylesheet asynchronously.

Shell -- install
npm install @11ty/eleventy-plugin-bundle
JavaScript -- .eleventy.js (add plugin)
const bundlePlugin = require("@11ty/eleventy-plugin-bundle");

module.exports = function (eleventyConfig) {
  eleventyConfig.addPlugin(bundlePlugin, {
    bundles: ["css", "js"],
    // Write bundle files to disk for hashed filenames
    toFileDirectory: "bundle",
  });

  // ... rest of config
};
Nunjucks -- _includes/base.njk (head section)
<!-- Inline critical CSS: above-the-fold layout, hero, nav, typography -->
<style>
{% getBundle "css" %}
</style>

<!-- Load full stylesheet asynchronously to avoid render blocking -->
<link rel="preload" href="/css/main.css" as="style"
      onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/main.css"></noscript>
Nunjucks -- page template (add critical CSS to bundle)
{% css %}
/* Critical: reset, body, nav, hero layout, typography -- only above-fold rules */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Satoshi, system-ui, sans-serif; line-height: 1.6; }
.site-header { position: sticky; top: 0; z-index: 100; background: #fff; }
.hero { display: grid; place-items: center; min-height: 60vh; }
.hero__image { width: 100%; height: auto; max-width: 1200px; }
{% endcss %}

The pattern here -- inline critical styles via the bundle plugin, async-load the full stylesheet -- is the same approach described in the critical CSS extraction guide. The bundle plugin makes it ergonomic within Eleventy's template system without requiring a separate PostCSS pipeline step.

Minify HTML output and run PurgeCSS

Eleventy outputs readable, indented HTML by default. For a 2,000-word article page, this typically adds 8-15KB of whitespace and comments that serve no purpose in production. An HTML minification transform removes this overhead at build time with no runtime cost. Combine this with PurgeCSS to strip unused CSS from your main stylesheet -- a typical Tailwind build goes from 35KB to under 8KB after purging.

Shell -- install
npm install html-minifier-terser @fullhuman/postcss-purgecss postcss postcss-cli
JavaScript -- .eleventy.js (addTransform)
const { minify } = require("html-minifier-terser");

module.exports = function (eleventyConfig) {
  // HTML minification transform -- runs on every .html output file
  eleventyConfig.addTransform("htmlMinify", async function (content, outputPath) {
    if (!outputPath || !outputPath.endsWith(".html")) {
      return content;
    }

    return minify(content, {
      removeComments: true,
      collapseWhitespace: true,
      removeAttributeQuotes: false, // Keep quotes for safety
      minifyCSS: true,              // Minify inline style attributes
      minifyJS: true,               // Minify inline scripts
      removeRedundantAttributes: true,
      removeScriptTypeAttributes: true,
      removeStyleLinkTypeAttributes: true,
      useShortDoctype: true,
    });
  });
};
JavaScript -- postcss.config.js (PurgeCSS)
const purgecss = require("@fullhuman/postcss-purgecss");

module.exports = {
  plugins: [
    purgecss({
      content: ["./_site/**/*.html"],
      defaultExtractor: (content) =>
        content.match(/[\w-/:]+(?<!:)/g) || [],
      safelist: {
        // Preserve dynamic classes added by JavaScript
        standard: [/^is-/, /^has-/, /^data-theme/, /^faq-item--open/],
        deep: [/^hljs/],
      },
    }),
  ],
};
JSON -- package.json build scripts
{
  "scripts": {
    "build": "eleventy && npm run css:purge",
    "css:purge": "postcss src/css/main.css --output _site/css/main.css",
    "start": "eleventy --serve"
  }
}

The PurgeCSS step runs after Eleventy finishes generating HTML, so it can analyze the actual output files rather than your source templates. This is important for catching classes generated dynamically by shortcodes. The safelist configuration preserves state classes that are added by JavaScript at runtime, which PurgeCSS cannot detect by analyzing static HTML.

Add preconnect, dns-prefetch, and prefetch hints

Resource hints cost almost nothing to add and can shave 100-300ms off LCP by warming up connections to third-party origins before the browser needs them. preconnect performs the DNS lookup, TCP handshake, and TLS negotiation upfront. dns-prefetch is a lightweight fallback for browsers that do not support preconnect. prefetch hints for likely next-page navigations improve perceived performance for multi-page journeys.

Nunjucks -- _includes/base.njk (head, after charset)
<!-- Preconnect to font origins to reduce first-paint latency -->
<link rel="preconnect" href="https://api.fontshare.com" crossorigin>
<link rel="dns-prefetch" href="https://api.fontshare.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://fonts.gstatic.com">

<!-- If using a third-party analytics endpoint, preconnect there too -->
<link rel="dns-prefetch" href="https://vitals.vercel-analytics.com">

<!-- Prefetch the next section a user is likely to visit -->
{% if prefetchUrl %}
<link rel="prefetch" href="{{ prefetchUrl }}">
{% endif %}

You can set prefetchUrl in your Eleventy front matter or data cascade to point at the most likely next page. For a blog listing page, this might be the most recent article. For an article page, this might be the next article in a series or the home page. The browser downloads the prefetched resource at low priority during idle time, so it does not compete with the LCP candidate.

Be selective about what you prefetch. Prefetching resources the user does not visit wastes bandwidth and can actually hurt LCP if it pushes the LCP image further back in the network queue on slow connections. Limit prefetch hints to one high-confidence next destination per page.

Deploy with edge cache headers on Cloudflare Pages or Netlify

Eleventy's static output is uniquely suited to CDN edge caching. Unlike server-rendered frameworks, every page is a pre-built HTML file with no per-request computation. This means you can set aggressive cache TTLs without worrying about serving stale dynamic data. The key is to use content-hashed filenames for CSS, JS, and images (so they can be cached forever) while keeping HTML TTLs shorter with stale-while-revalidate semantics.

Eleventy does not hash asset filenames by default, but you can add this via a build step or by using Eleventy's passthrough copy with a hashing plugin. A simpler approach that works well in practice: deploy to Cloudflare Pages or Netlify and let the CDN handle cache invalidation on deploy. Both platforms purge edge caches automatically on every deploy, so you can set HTML TTLs of 24 hours with confidence.

Text -- _headers (Cloudflare Pages)
# Place _headers in your Eleventy output directory (_site) or project root

# HTML pages: 24-hour TTL, stale-while-revalidate for background refresh
/*.html
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800

# CSS and JS: 1-year TTL (use hashed filenames in production)
/css/*
  Cache-Control: public, max-age=31536000, immutable

/js/*
  Cache-Control: public, max-age=31536000, immutable

# Generated images: 1-year TTL (eleventy-img produces content-hashed names)
/img/*
  Cache-Control: public, max-age=31536000, immutable

# Fonts hosted locally: 1-year TTL
/fonts/*
  Cache-Control: public, max-age=31536000, immutable
TOML -- netlify.toml (equivalent for Netlify)
[build]
  command = "npm run build"
  publish = "_site"

[[headers]]
  for = "/*.html"
  [headers.values]
    Cache-Control = "public, max-age=86400, stale-while-revalidate=604800"

[[headers]]
  for = "/css/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "/img/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "/js/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

Cloudflare Pages serves from 275+ edge locations worldwide and has a generous free tier. Netlify's CDN covers similar geographic distribution. Both platforms serve pre-compressed Brotli responses for text assets automatically, which reduces the payload of your CSS and HTML by an additional 20-30% compared to gzip. You do not need to configure compression manually -- it is on by default.

If your site uses dynamic functionality through Cloudflare Workers or Netlify Functions, keep those routes separate from your static HTML paths so the static pages benefit from full edge caching while only the API endpoints bypass the cache.

Verification

After applying all six steps, measure LCP using these tools in order:

  1. Lighthouse in Chrome DevTools (Incognito mode, CPU 4x slowdown). Run five times and average the results. Incognito prevents extensions from interfering. The LCP breakdown in the "Timings" section shows whether your bottleneck is TTFB, resource load delay, or resource load duration.
  2. PageSpeed Insights. Uses real CrUX field data if your URL has sufficient traffic, plus a Lighthouse lab measurement. Check both the "Field Data" (75th percentile real users) and the "Lab Data" (simulated mobile). Field data is the number Google uses for ranking.
  3. WebPageTest with a Cloudflare edge node as the test location. Run with "Repeat View" enabled to verify that cached resources are served on the second visit. Inspect the waterfall for any render-blocking requests that survived the critical CSS inlining step.

In the Chrome DevTools Performance tab, the LCP event appears as a green triangle in the timeline. Click it to see the LCP element, its render time, and the breakdown of network time versus rendering time. If the LCP element is your hero image, confirm the request starts within the first 50ms of navigation (indicating fetchpriority="high" was recognized) and that the Content-Type response header shows image/avif for modern browsers.

Quick checklist

  • @11ty/eleventy-img installed and generating AVIF + WebP at 400, 800, 1200, 1600px widths
  • Hero image shortcode uses fetchpriority="high" and loading="eager"
  • All below-fold image shortcodes use loading="lazy" and decoding="async"
  • @11ty/eleventy-plugin-bundle installed; above-fold CSS inlined via {% getBundle "css" %}
  • Full stylesheet loaded asynchronously with rel="preload" as="style"
  • HTML minification transform using html-minifier-terser wired via addTransform
  • PurgeCSS running as a PostCSS post-build step against _site/**/*.html
  • _headers (Cloudflare Pages) or netlify.toml sets 1-year TTL on assets, 24-hour TTL on HTML
  • LCP verified below 2.5s in PageSpeed Insights field data (below 1.8s in Lighthouse lab)

Common pitfalls

  • Using the synchronous eleventy-img API. The @11ty/eleventy-img package has both a synchronous and an asynchronous API. The sync API does not process images during the build -- it reads from a previously generated cache. Always use the async API inside an addNunjucksAsyncShortcode or addLiquidTag shortcode. Using the sync API in a regular (non-async) shortcode will cause Eleventy to serialize an unresolved Promise into your HTML output.
  • Inlining too much CSS as "critical". The benefit of inlining critical CSS comes from eliminating the render-blocking stylesheet request for styles that are needed to paint the first viewport. If you inline your entire stylesheet, you add kilobytes of HTML to every page response and lose the cacheability of external CSS. Keep the inlined critical CSS to the minimum needed to paint the above-the-fold content -- typically under 5KB.
  • Forgetting to exclude the _site/img/ directory from version control. The eleventy-img plugin writes generated images to your output directory. If you commit these to Git, your repository grows by megabytes per deploy. Add _site/ to .gitignore and let the CI/CD pipeline regenerate images on each build. This also applies to the Eleventy disk cache (.cache/ directory).
  • Setting fetchpriority="high" on the wrong element. The LCP element is not always the largest image in the design -- it is the largest element that is visible in the initial viewport. For text-heavy pages (documentation, articles with no hero image), the LCP element is often an <h1> or a large block of text. In that case, fetchpriority="high" on an image does nothing useful. Check Chrome DevTools to confirm which element the browser reports as the LCP candidate before applying hints.
  • Third-party scripts blocking HTML parsing. Eleventy sites frequently use third-party analytics (Fathom, Plausible, Google Analytics) loaded via a <script> tag in the base template. Any script without defer or async blocks HTML parsing and delays the LCP candidate from being discovered. Add defer to every third-party script tag. For analytics scripts that need to fire early, async is preferable to a blocking script but worse than defer for LCP.

Frequently asked questions

A fully optimized Eleventy site deployed on Cloudflare Pages or Netlify's edge CDN should achieve LCP under 1.6 seconds for most users on a 4G connection. Because Eleventy outputs pre-rendered static HTML with no server-side runtime, TTFB is typically under 80ms from a CDN edge node, which gives you the entire LCP budget for image and CSS loading. Sites serving AVIF images with inlined critical CSS regularly measure LCP at 1.0-1.4 seconds in Lighthouse.

No. eleventy-img generates the <picture> element markup and handles format conversion, but it does not know which image appears above the fold. You must explicitly pass fetchpriority: 'high' and loading: 'eager' as attributes when you call the image shortcode for your hero. Every other image shortcode call should use loading: 'lazy' and decoding: 'async'.

Yes. The recommended pattern is to inline only the critical above-the-fold CSS via the bundle plugin and load the full stylesheet asynchronously using a rel="preload" as="style" technique. This gives you fast first paint from inlined critical CSS while the complete stylesheet loads in the background without blocking rendering. Keep the inlined portion under 5KB to avoid bloating HTML response sizes.

Eleventy has a structural LCP advantage because it ships zero JavaScript framework runtime by default. There is no hydration cost and no client bundle to parse before the browser can render. Next.js and Astro can match Eleventy's LCP with aggressive optimization, but they require more configuration to eliminate framework overhead. For content-heavy sites like blogs and documentation, Eleventy's zero-JS default is a significant head start on LCP.

Either works. Nunjucks is the most popular choice for Eleventy projects and supports async shortcodes natively, which eleventy-img requires for its async API. Liquid also supports async shortcodes in Eleventy 2.0+. The key requirement is that you use the async shortcode pattern and await the Image call inside the shortcode function, otherwise Eleventy will serialize a Promise object into your HTML instead of the generated markup.

Continue learning