---
title: 'Image and Font Optimization'
source: 'https://academia.sh/en/courses/frontend-quality/image-and-font-optimization'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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?

```js
// 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

```html
<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 `sizes`
  declaration, 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: `swap` protects first contentful paint,
  `optional` protects 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.
