During a recent review of the website performance, I found that several category and tag pages were loading the original featured images used by each blog post. One of those files was 2,400 pixels wide and 1.24 MB, although it was displayed at 348 pixels wide on a mobile phone.
Cloudflare was returning the image from cache, which saved a request back to the origin server. The visitor still had to download the complete 1.24 MB file. This was contributing to a p75 Largest Contentful Paint (LCP) of 8.112 seconds for the featured image shown at the top of these pages.
In this blog post, I’ll cover how I traced the image requests, changed the loading behaviour for the first images on category and tag pages, and generated responsive WebP files during the Astro build. I’ll also cover the regional Google Analytics consent changes and the additional work completed from an Ahrefs site audit.
The code examples use Astro, Sharp and Cloudflare Pages. The same approach can be used with other static site generators if you can change the generated HTML and run an image task during the build.
The Issue
The initial field data showed a p75 LCP of 7.080 seconds, compared with 3.004 seconds during the previous seven days. Desktop was the main problem, with 2,246 samples and a p75 LCP of 7.096 seconds.
The CSS selector recorded with the field data pointed to the featured image inside the first post card on category and tag listing pages. These are the pages which list a group of posts, for example all posts in the VMware category or all posts with a particular tag.
The results for this image were:
| Segment | Samples | p75 LCP | Details |
|---|---|---|---|
| All measured traffic | — | 7.080 s | Previous seven days were 3.004 s |
| Desktop | 2,246 | 7.096 s | Mobile was 1.260 s across a much smaller sample |
| First post-card image | 1,293 | 8.112 s | 3.531 s resource delay and 1.468 s transfer |
An image LCP is split into the following four phases:
- time to first byte (TTFB);
- resource load delay;
- resource load duration; and
- element render delay.
This information helps you decide where to make the change. A smaller image reduces the resource load duration. It will not reduce a three-second delay before the browser requests the image. The web.dev LCP guide provides more information on each phase.
I tested a selection of page types instead of relying on the result from a single URL. The test covered the homepage, category and tag pages, several article pages with different sized hero images, and an article without an image above the fold.
I used the following browser profiles for each cold load:
| Profile | Viewport | Device pixel ratio | CPU | Network |
|---|---|---|---|---|
| Desktop | 1,440 × 900 | 1 | no slowdown | unthrottled |
| Mobile | 390 × 844 | 3 | 4× slowdown | Fast 4G |
A new browser context was used for each test so that an image from a previous page load was not already stored in the browser cache. For each LCP image I recorded:
- the LCP element and the four LCP phases;
- the encoded transfer size and request duration;
- the original image dimensions and its rendered size on the page;
- the request initiator and network dependency chain; and
- the cache response headers.
You can use curl for a quick check of an image response:
curl -sSI https://example.com/path/to/image.jpg \
| grep -Ei '^(HTTP/|content-type:|content-length:|cache-control:|cf-cache-status:)'
For a cached static asset, the output should look similar to the following:
HTTP/2 200
content-type: image/jpeg
cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
The cf-cache-status: HIT header confirms that Cloudflare served the file from its cache. Check the transfer size in the browser trace as well, because the cached copy can still be much larger than the rendered image needs.
Configure the Image Dimensions and Loading Priority
Each post on a category or tag page is displayed using the same card component. Previously, every featured image used the same loading settings, including images well below the visible part of the page.
I added an image rank while building the list of cards. The first two featured images load eagerly, and only the first image receives high fetch priority. The remaining images continue to use lazy loading.
---
let imageRank = 0;
---
{posts.map((post) => {
const rank = post.data.featuredImage ? imageRank++ : -1;
return (
<PostCard
post={post}
eager={rank >= 0 && rank < 2}
priority={rank === 0}
/>
);
})}
The image component uses those values to set the standard HTML attributes:
<img
src={src}
alt={alt}
width={width}
height={height}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={priority ? 'high' : undefined}
decoding="async"
/>
The width and height values are the actual dimensions of the source image, read with Sharp during the build. The browser can use them to calculate the aspect ratio and reserve space before the image finishes loading. See the MDN img element reference for more information on how the dimensions are used.
Only the first card receives fetchpriority="high". If the attribute is added to every image, they all compete in the same high-priority queue. The Fetch Priority guide recommends reserving it for the resource which is expected to become the LCP element.
The helper which reads image dimensions also checks that a content path stays inside the public directory:
import path from 'node:path';
import sharp from 'sharp';
const publicRoot = path.resolve(process.cwd(), 'public');
export async function readPublicImageSize(source) {
if (!source.startsWith('/') || source.startsWith('//')) return null;
const pathname = decodeURIComponent(
new URL(source, 'https://example.com').pathname,
);
const filePath = path.resolve(publicRoot, `.${pathname}`);
if (!filePath.startsWith(`${publicRoot}${path.sep}`)) return null;
const metadata = await sharp(filePath).metadata();
return { width: metadata.width, height: metadata.height };
}
Without the path check, a value in the content frontmatter could cause the build to inspect a file outside the intended media directory. I also cache the metadata promise by file path, as the same featured image can be used on a category page, a tag page and several paginated pages during one build.
Generate Responsive WebP Images
The first set of changes gave the browser the correct dimensions and loading priority. The browser was still downloading the original featured image, including the 1.24 MB file mentioned earlier.
The website has a WordPress media library collected over many years. It includes original files, WordPress-generated sizes, screenshots, downloads and animated GIFs. Processing every file would create images that are never selected by a page and add unnecessary work to every build.
I limited the image generator to featuredImage entries from published post frontmatter. These files are used by the category and tag cards, or as the large image at the top of an article.
Selecting the image widths
The browser traces showed the following rendered sizes:
| Layout | Desktop CSS width | Mobile CSS width | Largest useful source |
|---|---|---|---|
| Category or tag card | 574 px | 348 px | about 1,044 px at mobile DPR 3 |
| Article hero | 1,180 px | 370 px | about 1,110 px at mobile DPR 3 |
From these results, I selected widths of 320, 640, 960 and 1,280 pixels. The generator never enlarges a smaller original. It can also add the original width when the largest standard width would leave too large a gap.
const candidateWidths = [320, 640, 960, 1280];
export function selectWidths(intrinsicWidth) {
const selected = candidateWidths.filter((width) => width <= intrinsicWidth);
const largest = selected.at(-1);
if (!largest || (intrinsicWidth < 1280 && intrinsicWidth / largest > 1.1)) {
selected.push(intrinsicWidth);
}
return [...new Set(selected)].sort((a, b) => a - b);
}
These widths were selected for this layout. Before using them on another website, measure the content column and card widths on both desktop and mobile. Remember to include the device pixel ratio when deciding the largest useful source.
Deterministic image names
The generated filename is based on the public source path, the contents of the source file and a versioned transformation signature. This allows an unchanged image to reuse its derivatives on the next build.
import { createHash } from 'node:crypto';
const signature = 'webp-q82-effort4-smartsampling-v1';
function digest(value, length = 16) {
return createHash('sha256').update(value).digest('hex').slice(0, length);
}
const contentHash = digest(
Buffer.concat([sourceBytes, Buffer.from(signature)]),
);
const filename = `${safeStem}.${contentHash}.w${width}.webp`;
If the original file or the WebP settings change, the hash changes and the build creates a new filename. This also means the generated images can use an immutable cache header, as a changed file will have a different URL.
The Sharp transformation is:
const output = await sharp(sourceBytes)
.autoOrient()
.resize({ width, withoutEnlargement: true })
.webp({ quality: 82, effort: 4, smartSubsample: true })
.toBuffer();
Sharp documents the withoutEnlargement resize option and the available WebP output options. The transformation signature should be updated if you change the quality or processing settings, otherwise an existing file could be reused with the old settings.
The output is written to a temporary file and renamed after Sharp completes successfully. This prevents a failed build leaving a partial image which could be mistaken for a valid derivative later.
Creating the srcset and sizes attributes
The generator records each image and its derivatives in a small JSON manifest:
{
"version": 1,
"images": {
"/uploads/example.png": {
"width": 2400,
"height": 1260,
"variants": [
{ "url": "/_responsive/example.w320.webp", "width": 320 },
{ "url": "/_responsive/example.w640.webp", "width": 640 },
{ "url": "/_responsive/example.w960.webp", "width": 960 },
{ "url": "/_responsive/example.w1280.webp", "width": 1280 }
]
}
}
}
The Astro component reads the manifest and creates a picture element. The existing public image URL remains as the fallback src, so old links to the original WordPress upload continue to work.
<picture data-original-src={src}>
<source
type="image/webp"
srcset={variants.map((item) => `${item.url} ${item.width}w`).join(', ')}
sizes={sizes}
/>
<img
src={src}
alt={alt}
width={intrinsicWidth}
height={intrinsicHeight}
loading={loading}
fetchpriority={fetchpriority}
decoding="async"
/>
</picture>
Each w descriptor must match the real width of its derivative. The sizes attribute describes how wide the image is rendered by the page layout. The browser uses this information, together with the viewport and device pixel ratio, to select a file from the srcset. MDN has a good explanation in its responsive images guide.
For the measured layouts, the values are:
export const categoryAndTagSizes =
'(max-width: 680px) calc(100vw - 42px), ' +
'(max-width: 1220px) calc((100vw - 70px) / 2), 574px';
export const articleHeroSizes =
'(max-width: 1220px) calc(100vw - 40px), 1180px';
Astro can generate responsive images for imported assets using its own image components. I used a small wrapper because the original images are public WordPress upload paths. Those URLs have been used by search engines, old blog posts, downloads and external links, so the original files and paths need to remain available.
The generated WebP files are stored in an ignored build directory. Image generation runs before the Astro production build:
{
"scripts": {
"generate:responsive-images": "node scripts/generate-responsive-images.mjs",
"build": "npm run generate:responsive-images && astro build"
}
}
An unchanged build reports that it has reused the existing files:
Responsive images: 202 static featured sources; 0 derivatives created;
435 reused; 0 stale derivatives removed; 2 animated/unsupported sources preserved unchanged
The generated files use content-based names, so they can have a long browser cache lifetime:
/_responsive/*
Cache-Control: public, max-age=31536000, immutable
This cache header is applied only to the generated static images. I did not add a Cache Everything rule for HTML pages.
Before and After Results
I ran the same browser profiles against the new production build and compared the image bytes. A local server does not have the same TTFB as a request through Cloudflare, so I have not mixed local TTFB into the production comparison.
| Sample | Cold image bytes before → after | Saving | Representative request duration |
|---|---|---|---|
| Category page, desktop cards | 1,369,692 → 56,970 | 95.8% | 103.8 → 5.9 ms |
| Tag page, desktop cards | 1,327,266 → 54,476 | 95.9% | 103.5 → 6.8 ms |
| Category page, mobile cards | 1,315,506 → 77,310 | 94.1% | 1,432.4 → 223.5 ms |
| Tag page, mobile cards | 1,308,850 → 76,604 | 94.1% | 1,423.8 → 224.0 ms |
| Small legacy hero, mobile | 9,949 → 9,598 | 3.5% | 211.4 → 207.8 ms |
| Medium article hero, mobile | 42,294 → 18,064 | 57.3% | 281.4 → 237.4 ms |
| Large article hero, mobile | 31,740 → 12,504 | 60.6% | 256.9 → 218.3 ms |
The largest reduction was on the category and tag pages, where the browser no longer downloaded the full featured images for the first cards. On the Fast 4G mobile profile, the measured image bytes reduced by just over 94%, and the representative request reduced from approximately 1.43 seconds to 224 milliseconds.
The small article hero only reduced by 3.5% because its original file was already close to the required size. All of the sampled pages kept a Cumulative Layout Shift score of 0.00.
Regional Google Analytics Consent
Whilst reviewing the network dependency chain, I could also see when the Google Analytics request was loaded. The website needed a different default consent mode depending on the visitor’s location, whilst keeping the normal pages as static files.
I added a small same-site endpoint which reads Cloudflare’s country metadata and returns one of two policy values: opt-in or opt-out. The response does not include or store the visitor’s country.
const optOutCountries = new Set([
// Add only countries covered by your reviewed policy.
]);
function analyticsModeForCountry(country) {
const code = typeof country === 'string'
? country.trim().toUpperCase()
: '';
return optOutCountries.has(code) ? 'opt-out' : 'opt-in';
}
export function onRequestGet({ request }) {
const mode = analyticsModeForCountry(request.cf?.country);
return Response.json(
{ mode },
{
headers: {
'Cache-Control': 'private, no-store',
'Content-Type': 'application/json; charset=utf-8',
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff'
}
}
);
}
Cloudflare provides the two-letter country code on the incoming request.cf object. The endpoint uses Cache-Control: private, no-store to prevent one visitor receiving a cached policy response created for another location.
By default, adding a Pages Function can cause more routes to pass through server code. I restricted the function to its own endpoint using _routes.json:
{
"version": 1,
"include": ["/api/analytics-region"],
"exclude": []
}
The Cloudflare Pages Functions routing documentation explains how the include and exclude lists are applied. All other pages continue to be served as static assets.
The browser begins with consent denied. If the endpoint fails, returns an invalid value, cannot use storage or reaches its 1.5-second timeout, the code returns to the previous opt-in behaviour. In an opt-in location, the Google tag does not load until the visitor accepts analytics. Google describes this as Basic Consent Mode.
Advertising storage, advertising user data, advertising personalisation and Google Signals remain disabled when analytics is enabled:
const analyticsGranted = {
analytics_storage: 'granted',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied'
};
gtag('consent', 'update', analyticsGranted);
gtag('config', 'G-XXXXXXXXXX', {
allow_ad_personalization_signals: false,
allow_google_signals: false,
cookie_expires: 180 * 24 * 60 * 60,
cookie_update: false,
page_location: stripQueryAndFragment(location.href),
page_referrer: stripQueryAndFragment(document.referrer)
});
The stored preference includes a schema version, notice identifier, choice and the original timestamp. Its age is checked when the value is read, and regular page visits do not extend the 180-day lifetime. Search result pages are excluded from analytics so that a search term typed into the website is not sent as part of an analytics event.
The country list and default consent behaviour should be reviewed for your organisation, the data being collected and the applicable legal requirements. A change to the country list should include updated tests and, where required, an update to the privacy notice.
Additional Ahrefs Site Audit Work
As part of the additional site audit work, I reviewed the detailed exports from Ahrefs and compared them with the source content, generated HTML, live pages and preview build. The initial report included:
- 937 pages marked as
noindex; - 1,549 pages reported with missing image alternative text;
- 616 external redirects; and
- 59 oversized image URLs.
Some totals were caused by a common template or metadata setting. Others described pages which were already behaving as intended. I split the exports into individual page and target pairs before making any changes.
Issues corrected
The following repeatable issues were updated:
- 561 short descriptions belonged to indexable category, tag and yearly listing pages. Their descriptions are now created from the page type, page number and current post titles.
- The site-name suffix caused 201 article titles to exceed 70 characters. The suffix is now added only when the title is short enough, and two articles use a separate search metadata title.
- 13 internal URLs omitted their canonical trailing slash. These covered 23 links and 36 occurrences in the source content.
- Ten external links returning 404 were replaced with verified pages covering the same subject.
- One failed video preview image and five retired sponsor images were removed. The surrounding article links and locally stored historical media were retained.
- Cloudflare email address obfuscation was rewriting email-shaped example strings in technical articles into invalid
/cdn-cgi/l/email-protectionlinks.
For the final item, I wrapped the complete article content with Cloudflare’s documented email obfuscation suppression comments:
<!--email_off-->
<div class="article-content">
<!-- technical article HTML -->
</div>
<!--/email_off-->
The validation checks that the opening comment is before the article content and the closing comment is after it. Checking only that both comments exist would miss a closing comment placed too early in the page.
Findings retained after review
I left the following items unchanged after checking the generated pages:
- The 937
noindex,followpages were search results and low-value tag pages which are already excluded from the sitemap. - 2,626 empty alternative text values were decorative card images. Each is inside a duplicate image link which is removed from the keyboard tab order and marked
aria-hidden. - 49 of the 59 large image URLs were full-size download or link targets. They were not transferred as rendered images during a page load.
- Several external errors were authentication pages or sites blocking the Ahrefs crawler. The linked pages were still available to a normal browser.
- Seven pages reported as slow could not be reproduced. Their measured TTFB was between 0.103 and 0.214 seconds on the live and preview checks.
There are also old inline article images with empty alternative text. These need to be reviewed with the surrounding instructions and given useful descriptions. Automatically copying the filename into the alt attribute would not help someone using a screen reader.
I also inventoried animated GIF files at 18.9 MB, 9.0 MB and 4.1 MB. None were requested by the cold-load routes in this investigation, and two articles already display smaller WordPress-generated versions. I have kept the original files and URLs, and will review a GIF when a trace shows that it affects the page being tested.
Build and Validation
The production validator reads the generated HTML rather than checking only an Astro component in isolation. It verifies that:
- every image has valid intrinsic dimensions;
- the first image on category and tag pages is eager and high priority;
- the second image is eager and later images are lazy;
- responsive derivatives do not exceed the original image width;
- each
wdescriptor matches the real derivative width; - the
sizesvalue matches the relevant layout; - the fallback
srcis still the original public URL; - every generated image reference exists in the build output; and
- the Cloudflare email suppression comments contain the complete article body.
Unit tests cover deterministic filenames and output, reuse of generated files, missing or corrupt input files, path traversal, prevention of image upscaling and removal of stale output. The migration tests also cover reviewed link changes and content overrides, so a future import from WordPress cannot silently revert them.
The complete check can be run with:
npm run verify
git diff --check
For this build, npm run verify runs the unit tests, repository data checks, migration validation, Astro type checking, a production build and validation of the generated pages.
Summary
The first traces pointed to two separate delays for the main featured image. I updated the category and tag card components so that the browser discovers the first image early, reserves the correct space and gives that request high priority. I then added a selective image generator so the browser can download a WebP file matched to the rendered size.
The measured mobile transfer for the first category and tag cards reduced by just over 94%, from approximately 1.31 MB to 77 KB. Original WordPress upload URLs remain available, smaller source images are not enlarged, and files which are not used as featured images are not processed on every build.
If you are implementing something similar, I recommend starting with the LCP selector and a cold browser trace. Record the rendered image width, transfer bytes and four LCP phases before selecting any derivative sizes. Once the changes are in place, run the same trace again and add a build-time check for the generated HTML. This provides the information needed to confirm whether the browser selected the expected file and prevents a later component change from removing the loading or sizing attributes.
Regards