Skip to main content

Tales of… wait for it

Lazy Content Rendering

Add a few lines of CSS to save some 20-40 percent of rendering time in the browser.

The CSS property content-visibility is now supported by all major browser engines (Baseline 2025). Safari was the last one to add this feature in September 2024. This property allows the browser to skip most rendering work for off-screen content until it is near the viewport. You can already use it in projects, as older browsers will simply ignore unknown properties.

You are probably familiar with lazy loading for images.

<img src="example.png"
  loading="lazy"
  alt="…" width="…" height="…">

Here, the browser delays loading the image until it gets close to the viewport. The property content-visibility does something similar, but for rendering. In this case, HTML is parsed, the DOM is built, you can already search the page for text, CSS is parsed, but most rendering work is deferred. As the user scrolls and gets close to the content, the browser performs the deferred rendering work.

Let's first look at some complicated background before we concentrate on the modern easy approach.

Complicated containment

There is a CSS Containment Module that enables the isolation of page subtrees from the rest of the DOM. The browser can then improve performance by optimizing the rendering of these independent parts. There are four kinds of containment: Size Containment, Layout Containment, Style Containment, and Paint Containment. The contain property can take various keyword values:

contain: none;
contain: strict;
contain: content;
contain: size;
contain: inline-size;
contain: layout;
contain: style;
contain: paint;

These values can also be combined:

contain: size layout paint;
contain: inline-size layout;

I'll skip any attempt at explanation on my part. There is a good article on MDN with some demos that help to understand the basic intention. You might also want to look at this article on MDN or the specifications. But in the end, it boils down to this passage from this article on web.dev:

»It may be hard to figure out which containment values to use, since browser optimizations may only kick in when an appropriate set is specified. You can play around with the values to see what works best, or you can use content-visibility to apply the needed containment automatically. content-visibility ensures that you get the largest performance gains the browser can provide with minimal effort from you as a developer.«

Bottom line: Don’t try to understand containment, just use content-visibility instead.

Simple content-visibility

So, content-visibility has three possible values:

content-visibility: visible;
content-visibility: hidden;
content-visibility: auto;
  • visible is the default. Elements are rendered normally.
  • hidden makes the browser skip the element’s contents. The skipped contents will not be visible, not selectable or focusable, and can't be found with find-in-page. For a user, this has an effect similar to display: none, although it works differently internally: display: none removes the element from the layout. content-visibility: hidden keeps the element's own box and skips the rendering of its descendants.
  • auto is the value we want. In the background, this turns on layout containment, style containment, and paint containment. As long as the content is not in the viewport, the rendering will be skipped. Contrary to hidden, the content is selectable and focusable and can be found with find-in-page. The browser is free to decide exactly when off-screen content should be rendered; implementations typically start rendering shortly before the content enters the viewport.

A real-world example

So let's look at some pages from the German Jewish Museum in Berlin. On a typical page, the user sees the header, key visual, the content, teasers to other content, and the footer.

Page from jmberlin.de

An example page from the Jewish Museum Berlin

Page from jmberlin.de, with color coded content areas

With intro (yellow), content modules (green), teasers (blue), share icons (yellow), and footer

Let’s assume the user initially sees the intro text and maybe the first content item. The rest of the page is likely under the fold. Now we can tell the browser to skip some rendering:

.cm, /* These are content modules/items */
.share-icons
.teasers,
footer {
  content-visibility: auto;
}

I said, the first .cm might be visible directly. So we could add:

.intro + .cm {
  content-visibility: visible;
}

This will change the content-visibility of the first content item that directly follows the .intro. You could add this line. But it likely won’t have much effect. If we assume the item is in the viewport, the browser will render this with either content-visibility: auto or visible.

I am skipping one additional property here. I'll get to that later. Let’s see some rendering test results first.

Consistent faster rendering

I did a few tests with three different pages – without and with Cache, without and with Lazy Rendering. I opened the pages ten times and calculated the average. Yes, this means the results are not statistically reliable. The totals are affected by things like network variability, scripting, OS scheduling, cache, and background processes – these numbers are all over the place. The important parts are the rows with »Rendering«. Here, the savings on rendering time were consistent on each and every pageload.

Core Exhibition

mswithout Lazy Renderingwith Lazy Rendering
No Cache  
Rendering201143
Total43862945
Cached  
Rendering163114
Total10801186

Here we save around 30% rendering time.

Claude Lanzmann: The Recordings

 without Lazy Renderingwith Lazy Rendering
No Cache  
Rendering148 ms120 ms
Total2723 ms1848 ms
Cached  
Rendering128 ms103 ms
Total834 ms735 ms

Here we save around 20% rendering time.

Access Kafka

 without Lazy Renderingwith Lazy Rendering
No Cache  
Rendering128 ms75 ms
Total2523 ms2661 ms
Cached  
Rendering124 ms74 ms
Total769 ms1367 ms

This is one of our largest pages. Here we save around 40% rendering time.

All in all, a nice little performance boost on each and every pageload.

Easy improvements

If you test this for yourself, you will notice one minor annoyance. Because layout is skipped, the browser has no intrinsic size information for the skipped subtrees. Any items that are currently not rendered will have a height of 0. So the browser does not know the full height of the page. As the user scrolls down and new content gets rendered, the scrollbar will jump around as it adjusts itself to the new information.

This effect will vary on the device and the browser. On mobile phones and tablets, scrollbars are usually hidden and only appear briefly when scrolling. On Windows, Firefox nowadays shows only a minimal scrollbar. So some visitors might not even notice this.

You can reduce the effects by adding one property I have skipped over: contain-intrinsic-size. With this, you can tell the browser to use estimates for the width and height of the not-rendered items. 

On our website, the share icons and the footer are usually rendered with a certain height – as long as user agents don’t change margins or font sizes. On Desktop, the basic sizes are:

.share-icons {
  contain-intrinsic-size: 1132px 78px;
}
.footer {
  contain-intrinsic-size: 1132px 998px;
}

Here I’ve used the max-width of our items. The width placeholder can matter in layouts that depend on intrinsic sizing, such as some grid, flex, or multicolumn layouts, but in this case, the width is quite unnecessary for our purposes. We only care about reserving vertical space here. So instead of 1132px we could also use auto or 0 for the width.

.share-icons {
  contain-intrinsic-size: 0 78px; 
  /* or: */ contain-intrinsic-size: auto 78px;
}

I will use auto for the rest of the examples.

The height of the content items can be all over the place. Here, I just assume an average of 700px:

.cm {
  contain-intrinsic-size: auto 700px;
}

Then we have the teasers at the bottom. Here, I use a value for the average of two rows of teasers.

.teasers {
  contain-intrinsic-size: auto 1050px;
}

Final CSS

Put together, we end up with:

.cm {
  content-visibility: auto;
  contain-intrinsic-size: auto 700px;
}

.teasers {
  content-visibility: auto;
  contain-intrinsic-size: auto 1050px;
}

.share-icons {
  content-visibility: auto;
  contain-intrinsic-size: auto 78px;
}

footer {
  content-visibility: auto;
  contain-intrinsic-size: auto 998px;
}

Of course, this is just for the Desktop viewport. For every breakpoint, your estimates might change a bit. For the Jewish Museum I made some minor adjustments for tablet and mobile viewport sizes. But I didn’t add contain-intrinsic-sizes for every breakpoint.

Working with averages and estimates is fine. You do not need to finetune anything. But if you really want to do some unnecessary finetuning: see the appendix below.

Final assessment

On the pro side: On our pages, we observed 20–40% lower rendering time with just a few lines of code. It is up to you how much time you want to spend on optimizations for the scrollbar jumping. I don’t think you should put much effort into this.

On the contra side: In our best-case scenario, we are saving some 50ms of rendering time against some 4500 ms of total time (loading, scripting, rendering, painting, system), which is like a drop in the bucket.

In our case, we decided: The code is done, we are improving the performance – so of course we are keeping the changes.

Appendix: Some fancy CSS

Now, if you are a control freak who tends to waste too much time on little details, you might ask yourself: Can we do better estimates?

The height of the content items .cm has no pattern, so an estimated average is fine. But the teasers are another story. The row of three teasers has a min-height of 515px (on Desktop). So we could start with a height for one row. Every 4th, 7th, 10th ... element opens a new row; with some margin between rows. We can count this old school with nth-child:

.teasers {
  content-visibility: auto;
  contain-intrinsic-size: 1132px 515px;
}
.teasers:has(li:nth-child(4)) {
  contain-intrinsic-size: 1132px 1050px;
}
.teasers:has(li:nth-child(7)) {
  contain-intrinsic-size: 1132px 1585px;
}
.teasers:has(li:nth-child(10)) {
  contain-intrinsic-size: 1132px 2120px;
}
etc.

This is fine, even in older browsers. But surely there is a better way.

My first idea was to add the number of teasers as a data attribute.

<ul class="teasers" data-items="6">

And then do some calc() and round().

ul {
  height: calc( round(up, attr(data-items)/3) * 515px );
}

Surprise. This does not work at all! Cause that is not what attr is supposed to do. Or to be more precise: that is not what the old attr() was supposed to do. There is now a modern version of attr() which allows more shenanigans:

<ul class="teasers" data-items="15">
ul {
  height: calc(
    round(up, attr(data-items type(<number>))/3)
    * 515px );
}

Modern. Fancy. But of course not consistent. This works in Chromium, but not in Firefox or Safari. You could add a fallback and feature detection to this. But…

…it’s way easier to just use a CSS variable:

<ul class="teasers" style="--items:4;">
ul { height: calc( round(up, var(--items)/3) * 515px ); }

This works in every modern browser. Of course, we still have to know the number of items and add that number in the theme. Maybe that is not an option for everybody.

It would be nice if we could just count the number of items in CSS. There are CSS counters and there is the new sibling-count(). An ul can’t count its children. But you could count the siblings of an li:

li {
  --count: sibling-count();
}

But now that we are on the level of the lis, we could leave the ul alone and just do:

.teasers li {
  contain-intrinsic-size: 364px 515px;
}

And by this point at the latest, you come to the conclusion that this might be something we Germans call: »mit Kanonen auf Spatzen schießen«. Which means something like: using excessive force or an unnecessarily large solution for a small problem.

There is no need to micro-manage this. Just pick the easiest solution that suits you. An average value is fine. Our final CSS above is fine. Even for smaller viewports. Let the browser handle the rest.

Write a comment

House rules: Comments are moderated. The E-Mail will not be shown publicly, but in rare cases I might respond to a comment via mail. If your website points to a shop or shady website, I will not show the link.

The content of this field is kept private and will not be shown publicly.

Curiosity Clicker:
The message in the bottle
was clicked 0 times.