Guide 12 · Developer docs

Performance, caching, deployment and go-live

How Core dna caches pages, what makes a render fast, how code reaches a site and what stays behind, the go-live checklist, and running a site once it is live. For developers, leads and site operators who already build pages in a theme repository.

L2–L3
Unit 02 · Making pages fast

Images

Images are most of the bytes on most listing pages. Requesting the size you display, quality and format, lazy loading below the fold and loading the hero first, with numbers measured on a production site and worked out for a listing of thirty items.

Who this is for. Theme developers building listings, cards and heroes. Afterwards you can cut a listing's image weight by an order of magnitude and keep the hero fast, with numbers to justify each choice.

On a listing page, images are usually more than half of what the browser downloads, and the largest saving is the simplest: ask for the size the page shows, not the size the editor uploaded. How resizing and formats work is covered in full in Images end to end; this page is about what each choice costs.

Request the size you display

An uploaded image is served as uploaded unless the template asks for a size. One photograph on a production Core dna site, measured on 19 October 2026:

RequestedBytes
As uploaded: a 960 × 640 PNG270 KB
280 × 280, fitted, as a card shows it40 KB
560 × 560, fitted, for a high-density screen132 KB
1600 wide: larger than the original552 KB

The uploaded file is almost seven times the bytes of the one the card needs. The last row shows the other direction: a size larger than the original is enlarged, not refused, so the result is twice the bytes of the original with no more detail in it. Keep a requested size at or below the original, and ask for the card size in the template:

Template code
{$image = $post->getAreaImage('image')}
{if !$image->isBlank()}
  {$alt = $post->getName()|escape}
  {$image->getResized(280, 280, 'fit')->getHtml(['alt' => $alt, 'loading' => 'lazy'])}
{/if}
{% set image = post.getAreaImage('image') %}
{% if not image.isBlank() %}
  {{ image.getResized(280, 280, 'fit').getHtml({'alt': post.getName()|e, 'loading': 'lazy'}) }}
{% endif %}

getHtml() writes the image's width and height into the tag, with its aspect ratio, which reserves the space and stops the layout jumping as it loads. It does not escape the alt you pass, hence the escape above. Each new size is generated on its first request, which took over a second for every new size of the image above, and is a stored file from then on (resized images). Use a small fixed set of sizes across the site, so that visitors are not the ones generating them.

Quality and format

  • Quality applies to lossy formats, JPEG and WebP: the q URL parameter takes 1 to 100. On the PNG above, q=60 changed nothing, byte for byte. On a JPEG or WebP photo, lower quality pays most on large images and least on small ones; judge it on the real photos at the real size.
  • Format. toOptimizedFormat() converts an image to WebP (or JPEG if you ask for it), except SVGs and images already in that format, and the site's image settings can write a <picture> tag with a modern source and a fallback on their own (Picture tags and optimisation). Measure the result on your own images: the saving depends on the content of each photo.
  • High-density screens. A 2× version is more than three times the bytes of the 1× one here, 132 KB against 40 KB. Offer it through srcset so that only screens that use it download it, never as the only source (Picture tags and optimisation, its section on high-density versions).

Lazy below the fold, eager for the hero

loading="lazy" tells the browser to fetch an image only when it is about to scroll into view. On a grid of thirty cards of which six are visible on arrival, the first load fetches six images instead of thirty. Put it on every image that is below the first screen on any device.

The one image that must not be lazy is the one the page is judged by: the hero or the main product photo at the top. A lazy hero waits until the browser has laid out the page and decided it is visible, which delays the largest contentful paint. Load it eagerly, mark it as the priority, and give it its size:

Template code
{$hero = $page->getAreaImage('hero')}
{if !$hero->isBlank()}
  {$heroAlt = $hero->getName()|escape}
  {$hero->getResized(1600, 600, 'crop')->getHtml(['alt' => $heroAlt, 'loading' => 'eager', 'fetchpriority' => 'high'])}
{/if}
{% set hero = page.getAreaImage('hero') %}
{% if not hero.isBlank() %}
  {{ hero.getResized(1600, 600, 'crop').getHtml({'alt': hero.getName()|e, 'loading': 'eager', 'fetchpriority': 'high'}) }}
{% endif %}

Ask for a hero no wider than the uploaded original, for the reason in the table above. A hero set as a CSS background is invisible to the browser until the stylesheet is parsed. Prefer an <img> for it; where a background is unavoidable, add a <link rel="preload" as="image"> for the same URL in the head.

A listing of thirty items

Worked out from the sizes measured above, for one listing page of thirty cards with six visible on arrival:

TemplateImage bytes on first load
Original files, no lazy loadingabout 8.1 MB
Card size, no lazy loadingabout 1.2 MB
Card size, lazy below the foldabout 240 KB

The first change is the one that matters: a template that prints the original image is the most common reason a listing page is heavy, and it never shows on a fast office connection. Test listing pages on a throttled connection in the browser's developer tools before launch.

Go deeper

Was this page helpful?

Your answer goes to the docs team.