Lesson 03 / 27
Image and Font Optimization
Separating an image's transfer, decode, and paint costs; computing the correct file selection from a candidate set, deciding by format class, and the timeline of a font-loading strategy.
Contents
When the critical path shortens, first paint moves earlier. Largest contentful paint, on most pages, is not a block of text — it is an image: the card images on the station list and the station photo on the measurement detail page carry far more bytes than the document itself. Font files, in turn, determine when the text becomes readable.
This lesson takes up the size, format, and loading decisions for both asset types.
An Image’s Three Separate Costs
Image optimization usually gets reduced to a single number: file size. Yet the same image produces cost in three separate places, and they are independent of each other.
Transfer cost is the number of bytes downloaded. Compression lowers this.
Decode cost is turning the compressed bytes into pixels. It spends processor time, and the result occupies memory independent of compression: four bytes per pixel. This is the image counterpart of the transferred-size–resource-size distinction from the Network Panel Diagnostics lesson, and here the gap is far larger. A photo that compresses well does not tax the network — it taxes memory.
Paint cost is scaling the decoded pixels to the box on screen. If the source pixel count exceeds the target box, the surplus gets rescaled on every frame.
The second and third costs depend on the selected file’s pixel dimensions, not its file size. The following computation puts this distinction into numbers.
// candidates.mjs — which file gets selected from a candidate set, and the decoded pixel cost // station card image: aspect ratio 3:2 const candidates = [400, 800, 1200, 1600, 2400]; // width descriptors (w) const ASPECT_RATIO = 2 / 3; // sizes declaration: full width under a 720px viewport, 360px above it const layoutWidth = (viewportWidth) => viewportWidth <= 720 ? viewportWidth - 32 : 360; function selectedCandidate(viewportWidth, pixelRatio) { const needed = layoutWidth(viewportWidth) * pixelRatio; return candidates.find((a) => a >= needed) ?? candidates[candidates.length - 1]; } const decodedBytes = (width) => Math.round(width * width * ASPECT_RATIO * 4); console.log("viewport".padStart(9) + "ratio".padStart(7) + "layout".padStart(9) + "needed".padStart(9) + "selected".padStart(10) + "decoded".padStart(10) + "largest candidate".padStart(20) + "multiple".padStart(10)); for (const [viewport, ratio] of [[390, 2], [390, 3], [768, 1], [768, 2], [1280, 2]]) { const selected = selectedCandidate(viewport, ratio); const needed = layoutWidth(viewport) * ratio; const selectedBytes = decodedBytes(selected); const largestBytes = decodedBytes(candidates[candidates.length - 1]); console.log( `${viewport}px`.padStart(9) + `${ratio}x`.padStart(7) + `${layoutWidth(viewport)}px`.padStart(9) + `${needed}px`.padStart(9) + `${selected}w`.padStart(10) + `${(selectedBytes / 1024 / 1024).toFixed(2)} MB`.padStart(10) + `${(largestBytes / 1024 / 1024).toFixed(2)} MB`.padStart(20) + `${(largestBytes / selectedBytes).toFixed(1)}x`.padStart(10)); } console.log("decoded = width x height x 4 bytes (1 byte per channel, RGBA)"); console.log("multiple = how many times larger decoded memory would be if the largest candidate got selected");
viewport ratio layout needed selected decoded largest candidate multiple
390px 2x 358px 716px 800w 1.63 MB 14.65 MB 9.0x
390px 3x 358px 1074px 1200w 3.66 MB 14.65 MB 4.0x
768px 1x 360px 360px 400w 0.41 MB 14.65 MB 36.0x
768px 2x 360px 720px 800w 1.63 MB 14.65 MB 9.0x
1280px 2x 360px 720px 800w 1.63 MB 14.65 MB 9.0x
decoded = width x height x 4 bytes (1 byte per channel, RGBA)
multiple = how many times larger decoded memory would be if the largest candidate got selected
Selection happens in two steps. First, the element’s layout width gets resolved from
the sizes declaration; this is a rule set written with media queries, and the browser
produces a single CSS pixel value by looking at the viewport. Then this value gets
multiplied by the pixel ratio, and the smallest file in the candidate set that meets that
number gets selected.
What the table really shows is the last column. The same image’s largest candidate produces fourteen and a half megabytes of pixel data whenever it gets decoded, no matter the condition; on a narrow screen, though, a four-hundred-pixel file is enough and spends thirty-six times less memory. This gap does not show up in file sizes — the ratio between the two files’ compressed sizes is far smaller.
The same table holds a second observation: the card is smaller on a wide screen. In a
responsive layout, a single card’s width drops as the column count rises, so the
assumption that desktop always wants a larger image is wrong. The sizes declaration
must get derived from the layout, not from a guess.
Format Selection
The format decision gets made by content class, not by product names.
Vector or raster? Icons, charts, logos, and line graphics get defined by geometry; stored in vector format, they stay sharp at every scale, and their decoded memory depends on geometric complexity, not pixel count. A photograph is raster; it cannot get converted to vector.
Lossy or lossless? A lossy encoder compresses by discarding information the eye does not distinguish, and it is the right choice for a photograph. A lossless encoder preserves every pixel; in content with sharp edges — a screenshot, an image containing text, flat color regions — the blur lossy compression leaves behind becomes visible.
Is transparency needed? A format that supports an alpha channel should get chosen if it is; it should not if it is not, because a fourth channel adds data.
Does it offer progressive rendering? Some formats let a low-resolution version get produced from the file’s first bytes. On a large image, this separates the time between the user seeing something and seeing everything.
Different formats being supported by different clients gets solved with the picture
element: each option in the source list declares its own content type, the client skips
a type it does not recognize, and it eventually falls back to the img element. This is
the markup-level counterpart of feature detection; it requires no assumption about which
format will be supported.
When It Gets Requested
Once the correct file is selected, two decisions remain: when to request it, and whether to reserve its space up front.
Lazy loading defers requesting images outside the viewport. The rule’s one exception matters: the image that is a candidate for largest contentful paint must not be lazy-loaded. A deferred request delays the metric itself.
Fetch priority is the other end of the same problem. Every image in the document
gets requested at low priority by default; writing fetchpriority="high" on the hero
image in the first screen moves its request ahead of the others.
Space reservation is a stability decision, not a performance one, but it gets given
on the same element. When width and height attributes are written on an image, the
browser knows the aspect ratio before the file downloads and reserves the box up front.
Left unwritten, everything beneath the image shifts the moment it arrives — this is the
most common source of the score computed in the first lesson.
The Font’s Timeline
The Font Loading lesson in the Visual Presentation with CSS course separated
font-display values by two periods: during the block period no text gets painted,
during the swap period text gets painted with a fallback font. The question asked here is
different: what does the user see if the file arrives at a given moment?
// font-timeline.mjs — the state of the text depending on the file's arrival time // The three periods defined in the specification: block, swap, fallback retreat. const PERIODS = { block: { blockDuration: 3000, swapDuration: Infinity }, swap: { blockDuration: 100, swapDuration: Infinity }, fallback: { blockDuration: 100, swapDuration: 3000 }, optional: { blockDuration: 100, swapDuration: 0 }, }; function timeline(value, arrival) { const { blockDuration, swapDuration } = PERIODS[value]; const invisibleDuration = Math.min(arrival, blockDuration); if (arrival <= blockDuration) { return { invisibleDuration, fallbackDuration: 0, result: "requested font" }; } const swapEnd = blockDuration + swapDuration; if (arrival <= swapEnd) { return { invisibleDuration, fallbackDuration: arrival - blockDuration, result: "requested font" }; } return { invisibleDuration, fallbackDuration: Infinity, result: "fallback font stays" }; } const duration = (ms) => (ms === Infinity ? "for the page's lifetime" : `${ms} ms`); console.log("--- state of the text by file arrival time ---"); console.log("value".padEnd(10) + "arrival".padStart(9) + "invisible".padStart(11) + "fallback".padStart(24) + " final state"); for (const value of Object.keys(PERIODS)) { for (const arrival of [80, 900, 4200]) { const c = timeline(value, arrival); console.log( value.padEnd(10) + `${arrival} ms`.padStart(9) + `${c.invisibleDuration} ms`.padStart(11) + duration(c.fallbackDuration).padStart(24) + " " + c.result); } }
--- state of the text by file arrival time --- value arrival invisible fallback final state block 80 ms 80 ms 0 ms requested font block 900 ms 900 ms 0 ms requested font block 4200 ms 3000 ms 1200 ms requested font swap 80 ms 80 ms 0 ms requested font swap 900 ms 100 ms 800 ms requested font swap 4200 ms 100 ms 4100 ms requested font fallback 80 ms 80 ms 0 ms requested font fallback 900 ms 100 ms 800 ms requested font fallback 4200 ms 100 ms for the page's lifetime fallback font stays optional 80 ms 80 ms 0 ms requested font optional 900 ms 100 ms for the page's lifetime fallback font stays optional 4200 ms 100 ms for the page's lifetime fallback font stays
When the file arrives quickly, all five values give the same result; the distinction
appears in the slow condition. For a file arriving at nine hundred milliseconds, block
leaves the text invisible for nine hundred milliseconds, swap and fallback paint it
with a fallback font after a hundred milliseconds, and optional never uses the file on
that page at all.
The decision gets made by which metric gets protected. swap protects first contentful
paint and accepts the layout shift: text painted with the fallback font changes measure
when the real font arrives. optional protects layout stability and gives up the
designed font. block breaks both and is only defensible where a fallback would be
meaningless — icon fonts, for instance.
The duration values get given in the specification as recommendations and vary by implementation; the numbers in the model are taken from these recommendations. A changed number does not change the values’ ordering relative to each other.
There are three ways to reduce font cost, and all three improve this table: subsetting
the file to its own text reduces the bytes to download, requesting the file early with
preload moves the arrival time earlier, and bringing the fallback font’s metrics closer
to the real font with size-adjust and similar descriptors brings the shift at swap time
closer to zero.
The Station Card’s Markup
<article class="station-card"> <img src="/images/north-slope-800.img" srcset="/images/north-slope-400.img 400w, /images/north-slope-800.img 800w, /images/north-slope-1200.img 1200w" sizes="(max-width: 720px) calc(100vw - 32px), 360px" width="1200" height="800" fetchpriority="high" alt="The North Slope station's measurement pole beneath its snow cover"> <h2>North Slope 1</h2> </article>
The file extension in the example is a placeholder; it gets written according to the
selected format. The rule lies in the structure, not the extension: the candidate set
gets declared with real pixel widths, the sizes declaration gets derived from the
layout, width and height give the aspect ratio up front, and the priority declaration
gets written only on the first-screen card. If more than one format will be offered, the
same img element gets wrapped in a picture, and each source declares its own
content type; img remains as the last resort.
Cards in the list that fall outside the viewport carry no priority declaration;
loading="lazy" gets written on them instead. Two different instances of the same
component receive different declarations, and this distinction gets exposed as an option
in the component’s interface.
Summary
- An image’s transfer, decode, and paint costs are separate; decoded memory depends on pixel count, not file size, and the gap between them can span multiples.
- Candidate selection is two steps: the layout width gets resolved from the
sizesdeclaration, gets multiplied by the pixel ratio, and the smallest candidate that meets it gets selected; the declaration must get derived from the layout. - The format decision gets made by content class, not product name: vector or raster, lossy or lossless, whether transparency is needed, whether it offers progressive rendering.
- The image that is a candidate for largest contentful paint does not get lazy-loaded; the priority declaration gets written only on the first-screen image, and space reservation gets done on every image.
- Font display behavior is a metric preference:
swapprotects first contentful paint,optionalprotects layout stability.
Next Step
Every decision up to this point concerned the document and the assets themselves: which file, how big, when it gets requested. One more layer exists, and there a gain can get achieved without reducing a single byte. Does the same user opening the station list a second time have to redownload the same files? Does connection setup happen from scratch every time? The answer to these questions lies not in the request but in the headers the response carries and the connection hints written into the document. The next lesson takes up these headers by measuring them on a local server.
To keep your progress and take notes, Log in
My notes
Log in to take notes.