DEV Community

Cover image for On web rendering, layers and compositing.
tobiju
tobiju

Posted on

On web rendering, layers and compositing.

How a simple task led me to encounter compatibility issues with how different browsers create layers and composite those layers while they’re being animated, how I fixed it and what I learned along the way.

🌐 Browsers are constantly updated. The contents of this article were written based on Chrome 142 on Android and Safari 18.5 on iOS 18.5

While working at a fashion e-commerce startup, I had a task to update a full-screen modal on our web app’s mobile view to match the look and behaviour of its counterpart on our iOS app, which utilized the native iOS sheet component.

Sheets | Apple Developer Documentation

A sheet helps people perform a scoped task that’s closely related to their current context.

favicon developer.apple.com

Modals are a common component in web applications as they allow you to present information outside the usual content flow without leaving the page or screen containing the said content. We had several of them across the application, and for uniformity, I decided I would update all of them. I set out to start with the size modal on our product page, which is triggered to open when a user tries to add an item to their cart without selecting a size. It was a typical product page, containing basic product details and options, followed by a list of recommended products.

The iOS sheet component has a fluid slide-up animation when it opens and a nice scale reduction of the background so as to give more focus to the foreground. This was more visually appealing than what we had set up at the time, so it made sense to have this on the web as well.

The iOS sheet component

The iOS sheet component.

 

The perfect place to look was the Vaul library. Built by Emil Kowalski, he took inspiration from the very same iOS sheet component I was tasked with replicating.

After implementation and during testing, I discovered the slide-up animation on iOS (WebKit-based) browsers was janky and stuttered, making it pretty much unusable. And worse, on several occasions, it ended up crashing the entire page. This threw me off, considering it worked perfectly fine on Android and desktop browsers.

An uninformed guess was that this could be due to the main thread being too busy running some application JavaScript and the animation concurrently, so I commented out several blocks of logic that ran alongside the drawer animation, but this didn’t resolve the issue.

Now, as mentioned briefly, a key part of recreating the feel of the iOS drawer component is how the background scales back when the drawer is open. Vaul gives us this capability through the shouldScaleBackground prop. When true, Vaul scales down the background and does a 3D translation of its position downwards.

shouldScaleBackground in Vaul

I disabled this effect, and then re-tested. The rest of the drawer animation, which was simply the translation of the modal up and downwards, worked smoothly. Interesting.

Looking for a quick fix, I initially tried promoting the to-be-scaled-down background to its own layer using will-change: transform; property (which I will touch more on) without really understanding the specifics of what that even meant or did, and perhaps unsurprisingly, it didn’t work.

Now, each product item on the aforementioned list of recommended products on the page consists of a carousel of 3 images. And it was when I commented out this list and the animation, including the background scaling, began working fine again, that I began to get a slight idea of what was going on.

And on further investigation, I found out the issue was due to the high number of layers I had on the page, mostly due to these product recommendation components, the background scale animation suffering from this high layer count, and finally, the differences in how WebKit vs Chromium (and other browsers) handled these layers when the page was being rendered.


First, let’s lay a good foundation in some important concepts.

A layer, simply, is a group of visual elements that share the same coordinate plane, as explained in more detail here.

In the typical web page rendering pipeline, there are 5 areas: JS / CSS -> Style -> Layout -> Paint -> Composite.

Rendering pipeline overview

A simplified view of the rendering pipeline.

 

The key area here is compositing, which involves combining the different layers of the webpage to display on screen. This is handled by the compositor thread, which utilises the GPU and is separate from the main thread (that executes our JavaScript and calculates our styles).

During animations, depending on the property being manipulated, you can skip certain steps in the rendering pipeline. An important type of such properties is called Compositor-Only Properties. As the name suggests, these are properties that can be handled by the compositor thread, allowing us to potentially skip both the layout and paint stages and go straight to the composition stage from styling. This is important because skipping those two stages avoids potentially costly work that could introduce latency or jank in our animations. But to skip both stages, we need to promote whatever element we’re animating to its own compositor layer.

Stick to Compositor-Only Properties and Manage Layer Count  |  Articles  |  web.dev

Compositing is where the painted parts of the page are put together for displaying on screen.

favicon web.dev

Elements are promoted to their own layer under certain conditions and for different reasons, but mostly for performance of some kind, depending on the browser engine, but we can also manually promote elements traditionally through the transform: translateZ(0) property and in modern browsers through the will-change: transform property.

Simplify paint complexity and reduce paint areas  |  Articles  |  web.dev

Paint is the process of filling in pixels that eventually get composited to the users' screens. It is often the longest-running of all tasks in the pipeline, and one to avoid if at all possible.

favicon web.dev

You may then think it’ll be performant to promote each element to its own layer, but each layer created requires memory and management. On devices with limited memory, and eventually, on any device at all, as we would soon come to see, this additional memory and its management will have a negative impact on performance.


As mentioned earlier, I implemented the Vaul drawer on a product page, which had a list of product recommendations, each product item on that list consisting of a carousel, built with the Swiper library, of 3 slides.

To further demonstrate the bug and assist with visuals in this article, I built a repro of the bug: a toggle to open the drawer and a list of carousels. All media and data shown is from this recreation.

GitHub logo Tobijudah / vaul-iOS-perf-repro

Reproducing and fixing the iOS Safari crash caused by excessive compositor layers when using Vaul's shouldScaleBackground with Swiper.js carousels. Each commit walks through a fix step: from the initial bug to eliminating unnecessary layer promotions.

vaul-ios-perf-repro

Companion demo for the article "On web rendering, layers and compositing" (article in progress).

The article now documents this demo directly. The current write-up, screenshots, videos, and Android/iOS trial table are all based on this repo.

The demo reproduces an iOS Safari crash that happens when Vaul's shouldScaleBackground is combined with a long list of Swiper.js carousels. Each Swiper slide is promoted to its own compositor layer, and on WebKit the slide wrapper and its parent get promoted too. Safari then renders all those layers regardless of viewport position, and when the drawer opens, Vaul's background-scale animation forces a re-composite of the lot.

In the current demo-based measurements used in the article:

  • iOS Safari starts at 641 layers and 33.16 MB, then spikes to 646 layers and 798.73 MB while opening the drawer.
  • Chrome on Android also spikes during the animation (391 layers, 978

Using the Layers panel in the browser developer tools, I wanted to verify that the number of layers on the page wasn’t an outlier. Upon inspecting some popular and similar sites to ours on mobile, our layer count was significantly higher than usual. But why?

Checking Chrome on Android, I saw that the browser had created a layer for each slide in the carousel, even though I hadn’t manually instructed the browser to do so. Which meant these slides were meeting certain conditions set by the browser for when to composite an element.

Safari on iOS painted an even worse picture; not only did it create a layer for each slide in the carousel, it also created layers for the wrapper element of the slides and for the parent of that wrapper as well!

Safari Layers showing extra wrapper and parent layers

The differences between how Chrome on Android and Safari (which is essentially WebKit, which all browsers on iOS use, including Chrome) handled this didn’t end there. Chrome seemed to have smartly rendered only the layers in view and then some as a buffer. Only rendering new layers as they approach the viewport, and destroying old ones as they exit and cross what I would presume to be a buffer point, thereby maintaining a consistent number of layers in memory.

Meanwhile, Safari rendered every single layer, regardless of whether it was in the viewport or how far away it was

Safari rendering the entire page worth of layers

I could not even zoom out to show the entire page end to end, but it rendered it all.

 

This amount of layers puts a lot of pressure on the memory, especially on a mobile phone, but it’s still manageable. However, when we attempt to open the drawer, which triggers the background scaling animation, Safari then attempts to re-composite all these layers, further spiking the memory usage. And when the memory allocated to the page is exceeded, Safari crashes the web page. I further confirmed this by setting the animation duration to 0, and the instant “animation” did not crash the page. The number of layers was an issue, but animating them exacerbated the problem.

Meanwhile, as we’ve seen, Chrome on Android creates fewer layers and renders even fewer of them. Only rendering all layers for a split second during the animation before returning to rendering only what is in the viewport and the buffer area.

These optimizations I observed in Chrome on Android were, to the best of my knowledge, introduced in Chrome 96. I’m unsure why they don’t seem to work out of the box on WebKit mobile.

For those interested in the numbers, I did some limited testing.

iOS

Trial 1

Phase Layers Memory (MB) Notes
Start 641 33.16
Open drawer (layout transform) 646 798.73 Heavy stutter
Stabilized (drawer open + transform done) 645 55.13
Close drawer (spike during animation) 646 798.73 Stutter and jank
Final (drawer closed + transform done) - - Browser restarted the page

Trial 2

Phase Layers Memory (MB) Notes
Start 641 33.16
Open drawer (layout transform) 646 798.73 Heavy stutter
Stabilized (drawer open + transform done) 645 55.13
Close drawer (spike during animation) - - Page crashed

Android

Phase Layers Memory (MB) Notes
Start 52 292
Open drawer (spike) 391 978 Very slight delay before otherwise smooth animation
Stabilized open 55 559
Close drawer (spike) 391 978 Stutter
Stabilized closed 52 292

Now that I had figured out what the problem was, I had two paths to solving the issue. Either I find a way not to render off-screen layers, or I reduce the number of layers I render.

A straightforward way of limiting how much off-screen content is rendered is through the content-visibility: autoproperty. I won’t go into much detail on how it works, but a simplification is that it prevents the rendering of an element’s contents until they are needed, most likely when the element is near or in the viewport.

content-visibility: the new CSS property that boosts your rendering performance  |  Articles  |  web.dev

The CSS content-visibility property enables web content rendering performance benefits by skipping rendering of off-screen content. This article shows you how to use this new CSS property for faster initial load times, using the auto keyword. You will also learn about the CSS Containment Spec and other values for content-visibility that give you more control over how your content renders in the browser.

favicon web.dev

This is how the initial render of the page looks now, with content-visibility: auto set on each product item component. It only renders what is in view

content-visibility initial render

With content-visibility: auto, Safari starts much closer to what Chrome on Android was already doing by default.

 

And when scrolling, it smartly only renders what is in view + a buffer, as Chrome on Android does by default

If I did this, it would have solved the issue, as the drawer motion works fluidly now that there are fewer layers to re-composite during the animation. However, for some reason, when I was in the thick of the issue, I noted down that it didn’t work on Safari, only discovering while writing this article that it does. Weird.


Instead, I focused on reducing the number of layers rendered on the page, which I believe is still worthwhile, as most of them were unnecessary, as we will soon see. This approach would also improve the page’s rendering performance and memory usage.

When on the layers panels tab, you can select an element and view why it was promoted to its own layer. There were essentially three elements I had to tackle, and they were on their own layer for the following reasons:

  • Each swiper slide:
    • Element has "backface-visibility: hidden" style
    • Element has a 3D transform
    • Element may overlap another compositing element
  • The wrapper element for the slides: Element has a 3D transform
  • The main swiper parent element: Element clips compositing descendants

I was curious enough also to find the WebKit source code responsible for assigning these reasons. Here you can see the full list of reasons an element could be promoted to its own layer for in Layers3DContentView.js in WebKit

And here is the equivalent list in Chromium:

Starting with the slides themselves, I suspect the reason is that the backface-visibility: hidden style was set was for certain effects the library offers when swiping between slides. When using the Cube effect, for example, we would want to avoid the back side of the cube from being visible from the front. Or simply, we would want an opaque cube, not a transparent one, and backface-visibility: hidden ensures that. Here’s a good demo from MDN.

However, we’re not using the cube effect, or any other effect that requires this property, so overriding it to visible resolves the reason: Element has "backface-visibility: hidden" style.

backface-visibility: visible;
-webkit-backface-visibility: visible;
Enter fullscreen mode Exit fullscreen mode

Next, the Element has a 3D transform reason is because the library set transform: translateZ(0) on the slide. As I mentioned earlier, this was the older way of hinting to the browser that you, the developer, want this promoted to its own layer by adding a 3D transform on the element. Swiper presumably does this to support certain 3D effects and animations, but we don’t need that optimization here. So I also override that with transform: none.

With the slide no longer composited, the secondary reason Element may overlap another compositing element also disappears. That tag only applies to elements that are already composited, presumably to keep them separate when they might interleave with other composited content.

  👀 To verify this, I went digging into the WebKit source code…

I wanted to be sure of why and not just accept the last reason disappearing because I fixed the previous reasons. So here's the code path as of the latest version of WPE WebKit 2.51.1 released 25 October 2025.

We can see the condition compositingReasons.stacking (Layers3DContentView.js#L502) that returns the Element may overlap another compositing element reason we're investigating. This is part of the compositing reason-assigning method I linked earlier (Layers3DContentView.js#L467) called _updateReasonsList, defined as part of a Layers3DContentView class.

This method is called (Layers3DContentView.js#L462) in another method of that class called _updateLayerInfoElement (Layers3DContentView.js#L447). The WI.layerTreeManager.reasonsForCompositingLayer method in its second argument provides a callback with a compositingReasons argument, which we pass to the _updateReasonsList function.

Tracing upwards from here we find where layerTreeManager.reasonsForCompositingLayer is defined (LayerTreeManager.js#L190). It gets the reasons it passes to the callback function from another callback function target.LayerTreeAgent.reasonsForCompositingLayer (LayerTreeManager.js#L190)

Continuing upwards, moving into C++ land now, we find the LayerTreeAgent.reasonsForCompositingLayer method (InspectorLayerTreeAgent.cpp#L246) and inside it you can see (InspectorLayerTreeAgent.cpp#L293) where it sets stacking in compositorReasons to true when reasons.contains(CompositingReason::Stacking) resolves to true. But where is reasons in this context from? It's from renderLayer->compositor().reasonsForCompositing(*renderLayer) (InspectorLayerTreeAgent.cpp#L253)

So next we find the reasonsForCompositing method (RenderLayerCompositor.cpp#L3390) and in it we can see where it adds the stacking reason to the compositing reasons based on if the renderer.layer()->indirectCompositingReason() method returns IndirectCompositingReason::Stacking as the value.

indirectCompositingReason is a getter function (RenderLayer.h#L941) that returns the value of m_indirectCompositingReason which stores a IndirectCompositingReason. And setIndirectCompositingReason (RenderLayer.h#L1307) is the setter function that is used to set the value of m_indirectCompositingReason

So where is setIndirectCompositingReason called and assigned to IndirectCompositingReason::Stacking?

First we have the computeCompositingRequirements method (RenderLayerCompositor.cpp#L1260) which assigns a variable compositingReason to IndirectCompositingReason::Stacking if the compositingState.subtreeIsCompositing state is true (RenderLayerCompositor.cpp#L1290)

Looking inside the RenderLayerCompositor class we see another method updateWithDescendantStateAndLayer which assigns the subtreeIsCompositing state to true if either a subtree is compositing or more importantly if the layer is currently composited (RenderLayerCompositor.cpp#L183). This will resolve to true in our case as the layer was composited for other reasons.

Going back to the computeCompositingRequirements method (RenderLayerCompositor.cpp#L1338) the setIndirectCompositingReason setter function is eventually called with compositingReason variable which was set earlier as its argument

Now our layer count is down to 257 and 261 when the modal is open, from 641 and 645 respectively.

Moving to the next element, the swiper wrapper, we have the reason Element has a 3D transform due to the transform: translate3d(0px, 0, 0) style set on the element. In this case, we can't simply override it with transform: none, because for the carousel to function, the wrapper element is 3D-translated on the X axis as we move between slides. So we need to update the horizontal transform to a 2D transform instead. The library gives us a way to hook into this through the onSlideChange prop. So we can override the library's transform with our own 2D version, using translateX instead of translate3d.

onSlideChange={(swiper) => {
    const slideWidth =
        swiper.width /
        (typeof swiper.params.slidesPerView ===
        'number'
            ? swiper.params.slidesPerView
            : 1)
    swiper.wrapperEl.style.transform = `translateX(${
        -swiper.activeIndex *
        (slideWidth + productItemSwiperSpaceBetween)
    }px)`
}}
Enter fullscreen mode Exit fullscreen mode

But that only applies when we move between slides. On mount, the wrapper still has the default 3D transform style. To solve that, we use the onInit prop to override it immediately.

onInit={(swiper) => {
    swiper.wrapperEl.style.transform = 'translateX(0px)'
}}
Enter fullscreen mode Exit fullscreen mode

Lastly, we have the swiper parent element, previously composited because Element clips compositing descendants. Since the swiper wrapper is no longer composited, the parent has no reason to be composited either because it no longer contains compositing descendants.

The final result was my page having just one layer, the document or root scroller, and a finally smooth drawer animation with much lower memory usage.

Thanks for reading!

Other links

Top comments (0)