placeholderMaking Web Fast —— LCP Optimization

Making Web Fast —— LCP Optimization

🤖 Optimize LCP with preload, fetchpriority, responsive images and srcset. Covers Sharp image processing, CI/CD automation, and preload hints for better Core Web Vitals.

Intro

My blog embeds a fair number of high-resolution images, which hurts performance (mostly the Lighthouse score).

I found that using srcset for responsive images noticeably reduces the Load Time subpart of LCP.

lighthouse
lighthouse

without_srcset
without_srcset

Dev Tools

DevTools is the panel that pops up when you press F12. Two of its tabs are especially useful for frontend optimization: Performance and Lighthouse.

Take a personal blog as an example: the page’s LCP element is usually the post’s cover image. When the cover is large, the LCP score drops. Generally, the ideal LCP time is within 2.5s.

LCP

Each page’s LCP can be broken down into four consecutive (contiguous in time, non-overlapping) subparts:

LCP subpart% of LCPDescription
Time to first byte (TTFB)~40%The time from when the user initiates loading the page until the browser receives the first byte of the HTML document response.
Load delay<10%The time between TTFB and when the browser starts loading the LCP resource. If the LCP element doesn’t require a resource load to render (for example, if the element is a text node rendered with a system font), this time is 0.
Load time~40%The duration of time it takes to load the LCP resource itself. If the LCP element doesn’t require a resource load to render, this time is 0.
Render delay<10%The time between when the LCP resource finishes loading and the LCP element rendering fully.

Load Delay

Optimize Largest Contentful Paint | Articles | web.dev

The goal in this step is to ensure the LCP resource starts loading as early as possible. While in theory the earliest a resource could start loading is immediately after TTFB, in practice there is always some delay before browsers actually start loading resources.

In short, load delay is the “waiting period” between the end of TTFB and the moment the browser starts downloading the LCP resource (usually the hero image or a large block of text). The root cause of this delay: the browser doesn’t yet know which resource is the critical one to prioritize, and it has to parse some HTML or CSS before it can discover it.

load_delay
load_delay

👍 Do

Put a <link rel="preload" fetchpriority="high"> tag in the HTML <head> to tell the browser: “this resource (an image, or a third-party font file, e.g.) is important, start downloading it as early as possible.”

👎 Don’t

Lazy loading is a very common frontend performance technique: it defers loading resources outside the viewport to speed up initial load. But if you mistakenly apply it to the LCP element, it lengthens the “load delay” phase of LCP, which is the exact opposite of what LCP optimization is trying to achieve.


Example from Optimize Largest Contentful Paint | Articles | web.dev

<!-- Load the stylesheet that will reference the LCP image. --><link rel="stylesheet" href="/path/to/styles.css" /><!-- Preload the LCP image with a high fetchpriority so it starts loading with the stylesheet. --><link  rel="preload"  fetchpriority="high"  as="image"  href="/path/to/hero-image.webp"  type="image/webp"/>

Load Time

The goal of this step is to reduce the time spent transferring the bytes of the resource over the network to the user’s device.

Load Time is usually proportional to the number of bytes transferred, so most advice for improving it starts with reducing bytes. You can also speed up the transfer itself, or shorten the transfer distance.

  • Reduce resource size
    • Efficiently Encode Images…
    • Serve responsive Images…
    • Font loading can be optimized too, e.g. subsetting fonts or using woff2. This post focuses on image optimization, so I won’t go further into fonts here.
  • Speed up the transfer
    • Server bandwidth, use a CDN etc.

I won’t expand on transfer speed here; things like putting a CDN in front, or splitting traffic between domestic and overseas routes, are all worth trying.

Efficiently Encode Images

A fairly easy option: use a new-generation encoding format for images, such as avif or WebP. Google ships a CLI tool called cwebp, and the gowall tool I introduced in an earlier post can also convert images to WebP.

For day-to-day writing you can use PicList as your image upload tool, which simplifies the workflow a bit:

collect image -> upload (PicList auto-converts to avif/WebP) -> link auto-copied to clipboard

Server Responsive Images

Responsive images are a set of techniques used to load the right image based on device resolution, orientation, screen size, network connection, and page layout. The browser should not stretch the image to fit the page layout, and loading it shouldn’t result in wasted time or bandwidth. This improves user experience, as images load quickly and look crisp to the human eye.

In DevTools you can inspect an image’s Rendered Size & Intrinsic Size. If they differ too much, Lighthouse may warn about Properly size images, e.g. This image file is larger than it needs to be(6144 x 2630) for its displayed dimensions(1199x800). Use responsive images to reduce the image download size.

For the principles and concepts behind responsive images, see responsive_image

Method 1. Self-Host With Sharp & CI/CD Pipeline

Optimization approach, TL;DR

Basic idea: serve the most suitable image variant based on the user’s screen size, resolution, and so on, so small-screen devices don’t load oversized images.

Implementation:

  1. Modify the CI/CD pipeline so that at deploy time, tools like sharp automatically generate images at multiple sizes
  2. Modify the blog theme to set the srcset attribute on <img> and similar resources

Results

The newest post when I tested was vscode_easymotion; its hero image is a wallpaper downloaded from the catppuccin Discord. The size comparison below shows the optimization pays off nicely.

FormatResolutionSize
png6144 x 40966.8MB
WebP6144 x 40963.5MB
WebP(2000w)2000 x 133368.0kB
Step 1: Generate images at multiple sizes

Some image hosting providers can generate multiple sizes automatically, but I use Cloudflare R2 to host the images in my posts. Cover images and thumbnails for the Hexo blog live directly under the blog’s source/assets/cover and source/assets/thumbnails directories. A post’s YAML front matter looks like this:

---title: VSCode VIM Part I - EasyMotiondate: 2025-06-26thumbnail: /assets/thumbnails/vscode_vim.webpcover: https://assets.vluv.space/cover/ToolChain/easymotion.webptags: [VSCode, Vim, Catppuccin, EasyMotion]---Your blog post content goes here...

Given this layout, create a responsive_images.js script under <your_blog>/scripts (any other location works too) to generate the different sizes:

responsive.js
const sharp = require("sharp");const fs = require("fs");const path = require("path");// NOTE replace these with the directories that hold your images// e.g. '../source/assets/cover' and '../source/assets/thumbnails'const imageDirs = [  path.join(__dirname, "../source/assets/cover"),  path.join(__dirname, "../source/assets/thumbnails"),];const widths = [128, 256, 800, 1500, 2000];// Recursive function to find all files in a directoryasync function getFiles(dir) {  const dirents = await fs.promises.readdir(dir, { withFileTypes: true });  const files = await Promise.all(    dirents.map((dirent) => {      const res = path.resolve(dir, dirent.name);      return dirent.isDirectory() ? getFiles(res) : res;    })  );  return Array.prototype.concat(...files);}async function processImages() {  try {    let allFiles = [];    for (const dir of imageDirs) {      if (fs.existsSync(dir)) {        console.log("Scanning for images in:", dir);        const files = await getFiles(dir);        allFiles = allFiles.concat(files);      }    }    const imageFiles = allFiles.filter(      (file) =>        /\.(jpg|jpeg|png|webp|avif)$/i.test(file) &&        !/-\d+w\.(jpg|jpeg|png|webp|avif)$/i.test(file)    );    if (imageFiles.length === 0) {      console.log("No new images to process.");      return;    }    console.log(`Found ${imageFiles.length} images to process.`);    for (const imagePath of imageFiles) {      const { dir, name, ext } = path.parse(imagePath);      for (const width of widths) {        const newFileName = `${name}-${width}w${ext}`;        const newFilePath = path.join(dir, newFileName);        if (fs.existsSync(newFilePath)) {          // console.log(`Skipping, already exists: ${newFileName}`);          continue;        }        try {          await sharp(imagePath)            .resize(width)            .toFile(newFilePath);          console.log(` Generated: ${newFileName}`);        } catch (err) {          console.error(            ` Failed to process ${imagePath} for width ${width}:`,            err          );        }      }    }    console.log("\nImage processing complete!");  } catch (error) {    console.error("An error occurred during image processing:", error);  }}// Only run image processing in CI environmentif (process.env.RESPONSIVE_IMAGES === "true") {  console.log("CI environment detected. Running image processing script...");  processImages();} else {  console.log("Local environment detected. Skipping image processing.");}
Step 2: Set up the CI/CD Pipeline

Typically, static blog hosting pipelines let users specify the build steps themselves.

Edit the blog’s package.json and prepend node scripts/generate_responsive_images.js to the build script, so it runs before the static pages are generated:

package.json
{  "name": "hexo-site",  "version": "0.0.0",  "private": true,  "scripts": {    "build": "hexo gen",    "build": "node scripts/generate_responsive_images.js && hexo gen",    "clean": "hexo clean",    "deploy": "hexo deploy",    "server": "hexo server"  },  ...}
Tip

The script reads the RESPONSIVE_IMAGES environment variable; if it is true, the image processing logic runs, otherwise it is skipped. This way the script only runs in CI/CD, not during local testing.

With Cloudflare Pages, for example, you can add an environment variable RESPONSIVE_IMAGES in the project settings and set it to true

cf_page_responsive_image
cf_page_responsive_image

As for applying srcset in a theme, taking the Icarus theme I use as an example, see these two git commits of mine:

Method 2. Using CDN Provider’s API

CoreIX is Bitiful’s in-house, highly automated, intelligent, highly compatible, high-performance real-time media processing engine.

Media stored in S4 object storage can access all of CoreIX’s features simply by appending processing parameters to the URL (or in the GetObject API).

https://demo.bitiful.com/girl.jpeg?&w=300https://demo.bitiful.com/girl.jpeg?&fmt=avif
girl.jpeg&w?=300girl.jpeg?&w=300&fmt=avif
demo

With a CDN provider’s service, there is no need to process images in the CI/CD pipeline at all.

Ref