Web development

Core Web Vitals on a TYPO3 site: images, fonts and CSS

5 min read

Faster TYPO3 pages

When a TYPO3 page scores badly in PageSpeed Insights, the reason is rarely TYPO3 itself. TYPO3 delivers cached HTML fast. The slow part is what the HTML asks the browser to load: a 3000 pixel hero image, three web fonts from another server, a big CSS bundle and a slider script in the head.

This post goes through the three things I check first on any TYPO3 site, with the Fluid and TypoScript I use, and how to measure before and after. I will not promise you numbers. Your page is different from mine, and the only numbers that count are the ones you measure.

The three metrics in one paragraph

LCP (Largest Contentful Paint) is when the biggest visible element, usually the hero image or the main heading, is painted. CLS (Cumulative Layout Shift) measures how much the layout jumps while loading. INP (Interaction to Next Paint) measures how quickly the page reacts to clicks and taps. Images and fonts mostly hit LCP and CLS. Heavy JavaScript hits INP.

Step 1: Measure first

Before changing anything, take a baseline. For lab data, Lighthouse from the command line gives a report you can keep next to the later one:

npx lighthouse https://myport.ddev.site/ 
  --only-categories=performance 
  --output=html --output-path=./lighthouse-before.html

Then open the page in Chrome DevTools, go to the Performance panel and record a page load. The timeline shows the LCP element, when the fonts arrive and which requests block rendering. For INP, record while you click through the menu, accordions and forms.

Lab data on your fast laptop is not what visitors experience. If the site has enough traffic, PageSpeed Insights also shows field data from real Chrome users. Look at both.

Step 2: Images with real dimensions and srcset

The good news first: <f:image> writes width and height attributes from the processed image automatically. With img { max-width: 100%; height: auto; } in your CSS, the browser reserves the right space and the layout does not jump. If you build <img> tags by hand with f:uri.image, you lose this, and that is a common source of CLS.

The bigger problem is size. A phone does not need a 1600 pixel image. In TYPO3 14.2 and newer there is f:image.srcset (Feature #102215) for exactly this. Here the hero image comes from the media field of the page through the files data processor:

page.10.dataProcessing.20 = files
page.10.dataProcessing.20 {
  references.fieldName = media
  as = heroImages
}
<f:if condition="{heroImages.0}">
    <f:variable name="hero" value="{heroImages.0}" />
    <f:variable name="heroSrcset" value="{f:image.srcset(image: hero, srcset: '480w, 800w, 1200w, 1600w')}" />

    <f:image image="{hero}" width="1200"
             srcset="{heroSrcset}" sizes="100vw"
             loading="eager" fetchpriority="high" decoding="async" />
</f:if>

Arbitrary attributes like srcset, sizes and fetchpriority are passed through to the tag. The alt text comes from the file reference if you do not set it. Two rules that matter for LCP:

  • The hero image above the fold must not be loading="lazy". Lazy loading the LCP image delays it. Use eager and fetchpriority="high".
  • Images below the fold should be lazy. They then do not compete with the hero for bandwidth.

On TYPO3 13.4 there is no srcset ViewHelper yet. Build the attribute with f:uri.image per width instead:

srcset="{f:uri.image(image: hero, width: '480')} 480w, {f:uri.image(image: hero, width: '800')} 800w, {f:uri.image(image: hero, width: '1200')} 1200w"

Step 3: Preload what the browser finds too late

If the hero image is a CSS background, or the font is referenced only inside the CSS file, the browser discovers it late: first HTML, then CSS, then the image or font. A preload in the head starts the download right away.

TYPO3 14.0 added f:page.headerData (Feature #107056), so the preload can sit next to the image in the same template:

<f:page.headerData>
    <link rel="preload" as="image" fetchpriority="high"
          imagesrcset="{heroSrcset}" imagesizes="100vw">
</f:page.headerData>

Use the same srcset and sizes as on the <img>, otherwise the browser downloads two different files. The ViewHelper outputs its content as it is, so never put unescaped user input in there.

For a font, the TypoScript way also works on v13. This is how hoja.in preloads the one heading font that is visible above the fold:

page.headerData.10 = TEXT
page.headerData.10.value = <link rel="preload" href="{path : EXT:sitepackage/Resources/Public/Fonts/big-shoulders-display-latin-800-normal.woff2}" as="font" type="font/woff2" crossorigin>
page.headerData.10.insertData = 1

Preload only one or two files. If you preload everything, nothing is a priority any more.

Step 4: Fonts that do not block text

Web fonts cause two problems: invisible text while they load, and a layout jump when they arrive. What I do:

  • Self-host the fonts. No extra connection to a font server, and no visitor data going to a third party. On hoja.in the fonts are WOFF2 files inside the site package.
  • Use font-display: swap. Text is shown at once in a fallback font and swapped when the web font is ready.
  • Load only the weights you use. Every weight and style is one more file.
@font-face {
  font-family: "Atkinson Hyperlegible";
  src: url("../Fonts/atkinson-hyperlegible-latin-400-normal.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}

If the swap still causes a visible jump, choose a fallback font with similar metrics, or adjust it with size-adjust in a second @font-face rule.

Step 5: Keep render-blocking CSS and JavaScript small

Every stylesheet in the head blocks rendering until it is loaded. On many older TYPO3 sites I see a framework CSS, a theme CSS, a slider CSS and a plugin CSS, all in the head and all on every page.

  • Remove what is not used. The cheapest byte is the one you do not send.
  • Combine the rest into one file for the whole site. On hoja.in the redesign replaced the old jQuery theme with one site.css and one small site.js.
  • Scripts that are not needed for the first paint go to the footer or get defer. The small site.js on hoja.in stays blocking on purpose, because it sets a js class before the first paint, and there is a comment in the TypoScript that says so. Write such decisions down, or the next person “optimises” it away.

INP problems usually come from big scripts that run on every click: sliders, tracking, consent tools. Here the Performance panel helps again; look for long tasks after your interactions.

Step 6: Measure again

Run the same Lighthouse command with --output-path=./lighthouse-after.html and compare the two reports side by side, especially the LCP element and the “render-blocking resources” section. Change one thing at a time when you can, so you know which change helped.

That’s it. No magic extension, just images in the right size, fonts that do not hide text, and less in the head. If this post helped you, I will be really happy.

Further reading

Leave a comment

Your email address will not be published.