Images, Attachments, and Page Performance
Separate originals from display files and deliver images sized for their layout. Measure transferred bytes, response time, rendering, and interactions separately to locate slow stages.
Configure image storage, compression, and browser requests
If opening hours appear only in a poster image, tools that read text cannot easily find the days and exceptions in the body. If the poster URL also expires, they cannot retrieve the image itself. Write hours and closures in HTML and provide photos at accessible URLs with descriptions, creating separate ways to read the text and images. Google image guidance
Object storage stores images and attachments as keys and file contents. A bucket is a storage unit grouping files. The database can hold file locations, descriptions, and publication status, while storage holds originals and display variants. A signed URL permits file access through a signature and expiry conditions. Permanently saving a short-lived URL in a public page can make the image unavailable to later crawls.
WebP and AVIF are image file formats. Encoding converts pixels into a file format; decoding converts them back into pixels for display. EXIF holds additional information such as orientation and capture details. A hash is an identifier calculated from content and can distinguish original changes from opportunities to reuse variants.
src gives the default image URL, srcset the candidate URLs by size, and sizes the expected display width in the layout. DPR (Device Pixel Ratio) is the ratio of device pixels to one CSS pixel. The same display width can select a larger candidate at a higher DPR. Reserving space with width and height or aspect-ratio reduces shifts in surrounding text after the image loads.
Brotli and gzip compress transferred content such as HTML, CSS, and JavaScript. The server or CDN delivers it with the negotiated Content-Encoding. Reduce the file size of already compressed images during image conversion. Lazy loading a prominent image in the initial viewport can delay its display, so check request timing separately for initial-viewport and lower-page images.
CORS (Cross-Origin Resource Sharing) controls whether browser JavaScript can read a response from another origin. It differs from public access for server-to-server requests or ordinary document navigation. If a public API must be called from another domain in a browser, configure allowed origins. Apply separate authorization checks to APIs requiring authentication.
Understand and measure response time, page performance, and error rate
TTFB (Time To First Byte) is the time from starting a request until the first response byte arrives. DNS lookup, connection setup, network round trips, and server processing affect it. Read server processing time separately from application logs to locate the delay.
LCP (Largest Contentful Paint) marks when large primary content appears; CLS (Cumulative Layout Shift) describes unexpected layout movement. INP (Interaction to Next Paint) measures response delays from clicks, taps, and keyboard input to the next painted frame over an entire page visit. It generally uses the longest delay, excluding one highest value for every 50 interactions to handle outliers. Distinguish one click’s delay from visit-wide INP. The p75 across many visits’ INP values is another aggregation step. INP definition · Web Vitals
Lab data comes from tests under specified device and network conditions. Field data is measured in real users’ environments. p75 and p95 are the values at the 75% and 95% positions when observations are sorted from smallest to largest. Looking at the distribution alongside the average reveals cases where only some requests are very slow. Save the device, period, and sample count together.
Error rate is the proportion of target requests that failed. Count 5xx responses, timeouts, and WAF blocks separately. Cache hit rate is the proportion of cache lookups served with stored responses. A high hit rate can still return stale articles, so inspect the response revision too.
Visitors arriving from search face obstacles to reading and acting when they wait for a large image or an application button shifts during loading. LCP, INP, and CLS measure different aspects of this experience. Google states that its ranking systems use Core Web Vitals. To assess citations in generative answers, read actual citation results separately and distinguish changes to image access, text descriptions, and user experience. Page experience and search
Align CMS image delivery and caching with publication policy
Even a photo displayed small may download the entire original. In developer tools’ Network panel, compare the actual URL, response bytes, MIME type, and pixel dimensions with the displayed width. Check whether only CMS photos still deliver originals while static photos are optimized, whether srcset and sizes are output, and which candidate the browser selects.
Preserve originals as source material and create separate display variants. Set converter limits for input size, pixel count, allowed formats, and processing time. Apply orientation, resize, then encode in a supported format such as WebP or AVIF. Avoid enlargement beyond the original and check transparency and animation handling. If EXIF removal is needed, apply it to public variants without overwriting originals.
Include the original hash, output width, and encoder-settings version in variant keys. This reuses conversions of the same photo without wrongly reusing old files after replacing an original or changing quality settings. Choose supported widths and quality by comparing actual display size, DPR, and visual quality. Cards, body images, and enlarged views may each need different variant widths.
Use img srcset and sizes or a configured image component. sizes must match the image’s width in the actual grid. Declaring a small width for every card makes images blurry on large displays; always declaring full-screen width downloads unnecessarily large files. Reserve space with width and height or aspect-ratio. Google image guidance provides guidance on a fallback src, alternative text, and body context.
The unoptimized option in next/image skips built-in image conversion. A loader builds an image URL for a given width and quality. Check whether a custom loader serves variants and whether any components point to authenticated originals or directly to originals. Compare the selected variant’s width, size, and format in Network.
Design conversion reuse and access control separately. Material that must become inaccessible immediately after withdrawal can reuse privately stored variants while checking current publication status and permissions on every request. Long-lived immutable responses on a public CDN may still deliver files after withdrawal. Apply the same withdrawal conditions to original, variant, and general image-optimization URLs, and check for URLs that bypass access checks.
The default Next.js image optimizer does not forward authentication headers to the original request. Check the request structure for authenticated originals; a separate public-image route or variants from an existing image service may be used. With unoptimized, transfer size depends on whether src points to a small variant or a large original. Even if a loader builds /media/photo?w=640, the server must actually create a 640px file to reduce its size. Next.js Image configuration
Measure image transfer size separately from actual loading time
Compare before and after changes on the same page, viewport width, DPR, scroll range, and cache conditions. Record photo count, selected srcset candidates, response bytes, decoding, and visual differences from originals. Mixing first and repeat visits makes compression and cache effects hard to separate.
Reduced transfer size concerns downloaded file sizes. Measure LCP for primary-content display, INP for responsiveness, and CLS for layout shifts separately. Shrinking a large initial image may not improve LCP as expected if server responses or other resources are the bottleneck. Lighthouse measures under specified test conditions; real-user data comes from diverse devices and networks. Save these results separately to locate slow stages.
For field data, record the reporting period and page- or site-level aggregation scope. If insufficient data leaves no value, mark it unconfirmed. Compare transfer-size checks immediately after deployment separately from later user-experience aggregates and search impressions and clicks. Compare search impressions, clicks, and AI citations before and after in their respective observation records.
Inspect the image URL the browser selected
make-image-demo.mjs creates two geometric images and one HTML file. The images are 640px and 1280px wide, in WebP format. Run npm install --save-dev sharp@0.35.5 in the Next.js folder, then node make-image-demo.mjs. Start the server after generating the files. If it is already running, restart it so it recognizes the public files.
// Install: npm install --save-dev sharp@0.35.5
// Run: node make-image-demo.mjs (Next.js exercise project root)
import fs from 'node:fs/promises';
import sharp from 'sharp';
await fs.mkdir('public/photos', { recursive: true });
const source = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720">
<rect width="1280" height="720" fill="#e8eff5"/>
<path d="M160 540V180H1120V540Z" fill="#31516b"/>
<path d="M240 260H1040M240 360H1040M240 460H1040" stroke="#e8eff5" stroke-width="32"/>
</svg>`);
for (const width of [640, 1280]) {
await sharp(source).resize({ width }).webp({ quality: 80 }).toFile(`public/photos/shelves-${width}.webp`);
}
await fs.writeFile('public/image-demo.html', `<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Image size selection exercise</title>
<style>body{margin:16px}main{max-width:960px;margin:auto}img{display:block;width:100%;height:auto}</style>
<main><h1>Image size selection exercise</h1>
<img src="/photos/shelves-640.webp"
srcset="/photos/shelves-640.webp 640w, /photos/shelves-1280.webp 1280w"
sizes="(max-width: 992px) calc(100vw - 32px), 960px"
width="1280" height="720" alt="Exercise graphic showing three rows of shelves"
fetchpriority="high">
<p>A geometric image for checking file selection by size.</p>
</main></html>`);
console.log('Created public/image-demo.html and two WebP fixtures');
Buffer converts the SVG string to bytes for file processing; sharp’s resize, webp, and toFile save variants at each width. HTML src is the default URL, srcset lists candidates and actual pixel widths, and sizes gives display width in the CSS layout. sizes matches the body’s 16px margins on each side and main’s 960px maximum. width and height reserve a 16:9 space.
Open /image-demo.html on the exercise server at viewport widths of 390px and 1440px. In the developer-tools Console, inspect document.querySelector("img").currentSrc, naturalWidth, and getBoundingClientRect().width. Read the actual requested file and transferred bytes in Network. DPR and caching affect selection, so a narrow viewport may reuse a large file. Compare fresh contexts under the same DPR and cache conditions.
The prominent initial-viewport image has no loading="lazy". Apply lazy loading to lower images and inspect request timing before and after scrolling. alt describes the image’s meaning; purely decorative images use empty alt text. If a candidate returns 404, check public paths and srcset. If files remain larger than their display size, compare sizes with the actual layout.
srcset and sizes let the browser choose a candidate suited to the display. Receiving a suitable variant instead of a large original on a 390px viewport can reduce transfer size, while width and height reserve space before the image arrives. Inspect currentSrc, response bytes, and layout shifts to see what this code changed. Fix timetable information trapped inside an image with separate body text.
Compare original and variant requests for the same CMS image
The previous static-file exercise selected prebuilt files. This exercise checks publication status when CMS image ID photo is requested, then creates or reuses variants. Both HTML views keep the same layout: the original view outputs the original URL, while the variant view outputs srcset candidates. Comparing them shows whether a photo displayed small with CSS is actually a small file.
In an empty folder, save Public data's media-store.mjs, Documents and time's identity-time.mjs, Implementation's article-jsonld.mjs, URLs and crawling's feed.mjs. Save the following as media-demo.mjs. Use Node.js 22 and sharp 0.35.5, running in a separate folder from the existing Next.js exercise.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import sharp from 'sharp';
import { createMediaStore } from './media-store.mjs';
import { article, renderArticle, escapeHtml } from './article-jsonld.mjs';
import { renderFeed } from './feed.mjs';
export async function sampleImage() {
// A reproducible geometric image. Pass a real photo as an argument.
const rectangles = Array.from({ length: 1600 }, (_, i) =>
`<rect x="${(i * 67) % 1600}" y="${(i * 43) % 1000}" width="55" height="55"
fill="hsl(${i % 360},70%,50%)"/>`).join('');
return sharp(Buffer.from(`<svg width="1600" height="1000">${rectangles}</svg>`))
.jpeg({ quality: 95 }).toBuffer();
}
export function createDemoServer(store, origin = 'http://127.0.0.1:3031') {
return http.createServer(async (req, res) => {
const send = (status, type, body, extra = {}) => {
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'private, no-store, max-age=0', ...extra });
res.end(body);
};
try {
const url = new URL(req.url, origin);
if (req.method !== 'GET') return send(405, 'text/plain', 'Method not allowed');
if (url.pathname === '/media/photo') {
const width = url.searchParams.has('w') ? Number(url.searchParams.get('w')) : null;
const r = await store.read(width);
return send(r.status, 'text/plain', r.body, r.headers);
}
if (url.pathname === '/rss.xml') {
// This exercise publishes and withdraws the article and one photo together.
const visible = (await store.read()).status === 200;
return send(200, 'application/rss+xml; charset=utf-8', renderFeed(visible ? [article] : [], origin,
new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')));
}
if (url.pathname === '/video.html' || url.pathname === '/talk.mp4') {
const body = await readFile(new URL('.' + url.pathname, import.meta.url));
if (url.pathname.endsWith('.html')) return send(200, 'text/html; charset=utf-8', body);
const headers = { 'Accept-Ranges': 'bytes', 'Content-Length': body.length };
if (!req.headers.range) return send(200, 'video/mp4', body, headers);
// Single byte range for a small local video. Use file streams or a media server in production.
const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
let start = match?.[1] ? Number(match[1]) : Math.max(0, body.length - Number(match?.[2]));
let end = match?.[1] && match[2] ? Math.min(Number(match[2]), body.length - 1) : body.length - 1;
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start)
|| !Number.isSafeInteger(end) || start > end || start >= body.length)
return send(416, 'text/plain', '', { 'Content-Range': `bytes */${body.length}` });
return send(206, 'video/mp4', body.subarray(start, end + 1), {
...headers, 'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
});
}
if (url.pathname !== '/' && url.pathname !== article.path) return send(404, 'text/plain', 'Not found');
const candidates = await Promise.all([320, 640, 1280].map(async width => {
const r = await store.read(width);
return { ...r, path: `/media/photo?w=${width}` };
}));
if (candidates.some(c => c.status !== 200)) return send(404, 'text/plain', 'Not found');
const largest = candidates.at(-1);
if (url.pathname === article.path)
return send(200, 'text/html; charset=utf-8', renderArticle(origin, largest).html);
// Do not enlarge small originals. Keep one candidate per resulting width.
const unique = [...new Map(candidates.map(c => [c.width, c])).values()];
const srcset = unique.map(c => `${c.path} ${c.width}w`).join(', ');
const original = url.searchParams.get('mode') === 'original';
return send(200, 'text/html; charset=utf-8', `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>CMS image delivery exercise</title><link rel="alternate" type="application/rss+xml" href="/rss.xml">
<style>body{margin:16px;font-family:sans-serif}img{display:block;width:min(100%,640px);height:auto}</style>
</head><body><h1>${original ? 'Original' : 'Variant'} delivery</h1>
<p><a href="/?mode=original">View original</a> · <a href="/">View variants</a>
· <a href="${article.path}">Article</a> · <a href="/video.html?t=4">Video at 4 seconds</a></p>
<img src="${original ? '/media/photo' : largest.path}"
${original ? '' : `srcset="${escapeHtml(srcset)}" sizes="(max-width:672px) calc(100vw - 32px), 640px"`}
width="${largest.width}" height="${largest.height}" alt="Geometric image for comparing image delivery">
</body></html>`);
} catch (error) {
send(error.code === 'ENOENT' ? 404 : 500, 'text/plain', 'Resource unavailable');
}
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const source = process.argv[2] ? await readFile(process.argv[2]) : await sampleImage();
const store = await createMediaStore(source);
createDemoServer(store).listen(3031, '127.0.0.1', () => console.log('http://127.0.0.1:3031'));
}
npm init -y
npm install sharp@0.35.5
node media-demo.mjs
# Compare responses in another terminal
curl -sS -D original-headers.txt http://127.0.0.1:3031/media/photo -o original.jpg
curl -sS -D variant-headers.txt "http://127.0.0.1:3031/media/photo?w=640" -o variant.webp
# After stopping the server, you can also run it with your own photo.
# node media-demo.mjs ./photo.jpg
sampleImage creates a 1600×1000 geometric JPEG. Pass a path to your own photo as an argument to process it through the same route. The server binds only to 127.0.0.1 and has no public upload feature. /media/photo serves the original, ?w=640 a variant, /news/new-hours the article, and /rss.xml the feed. The video link works after preparing the two files and MP4 from the next Content chapter.
Candidate widths are 320, 640, and 1280. sizes reflects 32px of total horizontal margins and a maximum display width of 640px. At a 390px viewport the display width is 358px; DPR 2 needs about 716 pixels. Thus even a narrow viewport may select the 1280 candidate. Browser decisions, caching, and connection conditions also affect selection, so viewport width alone does not imply the 320 file.
With a small original, several candidates may have the same actual width. unique combines candidates with identical actual widths. Use those resulting widths in srcset and the actual aspect ratio in img width and height. After resizing the viewport, compare response bytes and file-metadata width as well as currentSrc.
This server prepares all three variants before building HTML to obtain their actual dimensions. A production CMS can save variants and sizes on upload, avoiding three conversions on the first page request. Connect upload completion to readiness for publication and choose a fallback image for conversion failures. When replacing a photo, also update its alternative text and description.
The exercise treats the article and one photo as material published together. Withdrawing the photo makes the article and home page return 404 and removes the article from RSS. If a real CMS allows changing a photo independently, store body and image publication status separately and decide whether to serve the body without the private image.
Record file size, download timing, and display timing in the same run
The following inspect-image.mjs runs eight combinations of 390/1440px, DPR 1/2, and original/variant in fresh browser contexts. It counts response-body bytes and uses sharp to read actual file width. In the browser it reads display width, currentSrc, Resource Timing, and LCP candidates. It waits for img.decode(), then advances frames to inspect rendering.
Before measuring, request the home page once to prepare server-side variants. Create a fresh browser context each time to reduce effects from prior image selection and browser caching. To investigate initial conversion cost, restart the server and separately record server processing time for the first request.
import { chromium } from 'playwright';
import sharp from 'sharp';
const browser = await chromium.launch();
const rows = [];
try {
// Prepare server variants first. Create a fresh browser context each time.
const warm = await fetch('http://127.0.0.1:3031/');
if (!warm.ok) throw new Error('Image server unavailable');
await warm.text();
for (const width of [390, 1440]) for (const dpr of [1, 2]) for (const mode of ['original', 'variants']) {
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: dpr });
try {
const page = await context.newPage();
await page.addInitScript(() => {
window.imageLcp = [];
new PerformanceObserver(list => {
window.imageLcp.push(...list.getEntries().map(e => ({ time: e.startTime, url: e.url, tag: e.element?.tagName })));
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
const imageResponse = page.waitForResponse(r => new URL(r.url()).pathname === '/media/photo');
await page.goto('http://127.0.0.1:3031/' + (mode === 'original' ? '?mode=original' : ''));
const response = await imageResponse;
const bytes = await response.body();
const metadata = await sharp(bytes).metadata();
await page.locator('img').evaluate(img => img.decode());
// Wait two frames to read rendering and LCP entries after decoding.
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const observed = await page.locator('img').evaluate(img => {
const resource = performance.getEntriesByName(img.currentSrc).at(-1);
return { currentSrc: img.currentSrc, cssWidth: img.getBoundingClientRect().width,
naturalWidth: img.naturalWidth, dpr: devicePixelRatio,
resource: resource && { start: resource.startTime, responseStart: resource.responseStart,
responseEnd: resource.responseEnd, encodedBodySize: resource.encodedBodySize, transferSize: resource.transferSize },
lcp: window.imageLcp.at(-1) ?? null };
});
rows.push({ width, mode, ...observed, bodyBytes: bytes.length,
fileWidth: metadata.width, fileHeight: metadata.height, format: metadata.format,
cacheControl: response.headers()['cache-control'] });
} finally { await context.close(); }
}
} finally { await browser.close(); }
console.log(JSON.stringify(rows, null, 2));
# Run in another terminal in the same exercise folder while the image server is running
npm install --save-dev playwright@1.59.1
npx playwright install chromium
node inspect-image.mjs > image-observations.json
bodyBytes is the received image-body size, fileWidth the file’s pixel width, and cssWidth the displayed width. responseStart to responseEnd covers response reception; start to responseStart includes request preparation and server waiting. transferSize is the browser-reported transferred size including headers. Remote URLs may report zero for some values depending on conditions such as Timing-Allow-Origin.
Read LCP tag and url together to determine whether the largest element is a photo or heading. Reduced photo downloads can still leave display delays if HTML arrives late or the image request is discovered late. This code saves eight runs on a local device. For production-speed comparisons, repeat under the same browser, device, network, and cache conditions and inspect the distribution. Use browser Performance recordings to investigate decoding time and detailed causes of main-thread blocking.
TBT sums the portions of main-thread tasks exceeding 50ms during the observation period after FCP (First Contentful Paint). One 120ms task creates 70ms of blocking time. INP evaluates the delay from user interactions such as clicks and input until the screen responds. Even a page with low TBT can have slow interactions if a menu opened later has an expensive handler. Investigate loading records and actual interactions separately. How TBT and INP relate
Late fonts can delay text display or change line wrapping when a fallback font is replaced. font-display: swap uses a fallback after a short wait, while preloading the required font starts its request earlier. Different widths between fallback and body fonts can leave CLS. Preload only files used in the initial viewport and inspect actual mobile font requests, text display, and line-wrap shifts. How font-display works
In one local run of this geometric image, the original was 238,236 bytes, the 640px WebP 28,676 bytes, and the 1280px WebP 49,546 bytes. At 390px and DPR 1 it received the 640px file; at the same viewport and DPR 2 it received 1280px. This shows how transfer size varies with physical-pixel requirements even for the same original. Remeasure file sizes when the photo, quality settings, or encoder changes.