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.
- Add
fetchpriority="high"andloading="eager"to your hero image shortcode call. - Configure
eleventy-imgto output AVIF + WebP at widths 400, 800, 1200, and 1600. - Inline above-the-fold CSS with
eleventy-plugin-bundleto eliminate render-blocking stylesheets. - Add an
addTransformusinghtml-minifier-terserto strip HTML whitespace. - Deploy on Cloudflare Pages or Netlify and set long-lived
Cache-Controlheaders 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-imgplugin 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
fetchpriorityon 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. Thefetchpriority="high"attribute on theimgtag 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-Controlheaders 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>withoutdeferorasyncblock 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.
npm install @11ty/eleventy-img
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.
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.
{# 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"
}
%}
{# 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.
npm install @11ty/eleventy-plugin-bundle
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
};
<!-- 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>
{% 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.
npm install html-minifier-terser @fullhuman/postcss-purgecss postcss postcss-cli
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,
});
});
};
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/],
},
}),
],
};
{
"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.
<!-- 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.
# 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
[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:
- 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.
- 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.
- 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-imginstalled and generating AVIF + WebP at 400, 800, 1200, 1600px widths -
Hero image shortcode uses
fetchpriority="high"andloading="eager" -
All below-fold image shortcodes use
loading="lazy"anddecoding="async" -
@11ty/eleventy-plugin-bundleinstalled; above-fold CSS inlined via{% getBundle "css" %} -
Full stylesheet loaded asynchronously with
rel="preload" as="style" -
HTML minification transform using
html-minifier-terserwired viaaddTransform -
PurgeCSS running as a PostCSS post-build step against
_site/**/*.html -
_headers(Cloudflare Pages) ornetlify.tomlsets 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-imgpackage 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 anaddNunjucksAsyncShortcodeoraddLiquidTagshortcode. 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. Theeleventy-imgplugin writes generated images to your output directory. If you commit these to Git, your repository grows by megabytes per deploy. Add_site/to.gitignoreand 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 withoutdeferorasyncblocks HTML parsing and delays the LCP candidate from being discovered. Adddeferto every third-party script tag. For analytics scripts that need to fire early,asyncis preferable to a blocking script but worse thandeferfor 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.
Related resources
Continue learning
Fix LCP in Astro
Apply similar image optimization and critical CSS techniques in Astro's component-based architecture.
FixFix LCP in Next.js
Optimize LCP using next/image priority, ISR, and React Server Components.
FixResponsive Images for LCP
Cross-framework guide to srcset, sizes, picture, and modern format selection for fast image loading.
FixCritical CSS Extraction
Eliminate render-blocking stylesheets by identifying and inlining only the CSS needed for the first viewport paint.