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.
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.
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 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.
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.
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.
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.
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.
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
641layers and33.16 MB, then spikes to646layers and798.73 MBwhile opening the drawer. - Chrome on Android also spikes during the animation (
391layers,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!
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
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.
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: 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;
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.
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)`
}}
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)'
}}
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!






Top comments (0)