<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: tobiju</title>
    <description>The latest articles on DEV Community by tobiju (@tobiju).</description>
    <link>https://dev.to/tobiju</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F360180%2F5cf15e14-6f2b-4d7f-8148-c3345a1b8efc.jpg</url>
      <title>DEV Community: tobiju</title>
      <link>https://dev.to/tobiju</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tobiju"/>
    <language>en</language>
    <item>
      <title>On web rendering, layers and compositing.</title>
      <dc:creator>tobiju</dc:creator>
      <pubDate>Mon, 13 Apr 2026 10:04:54 +0000</pubDate>
      <link>https://dev.to/tobiju/on-web-rendering-layers-and-compositing-3lk5</link>
      <guid>https://dev.to/tobiju/on-web-rendering-layers-and-compositing-3lk5</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;


&lt;div class="crayons-card c-embed"&gt;

   🌐 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
&lt;/div&gt;


&lt;p&gt;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. &lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://developer.apple.com/design/human-interface-guidelines/sheets" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdocs.developer.apple.com%2Ftutorials%2Fdeveloper-og.jpg" height="420" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://developer.apple.com/design/human-interface-guidelines/sheets" rel="noopener noreferrer" class="c-link"&gt;
            Sheets | Apple Developer Documentation
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            A sheet helps people perform a scoped task that’s closely related to their current context.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdeveloper.apple.com%2Ffavicon.ico" width="64" height="64"&gt;
          developer.apple.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;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 &lt;strong&gt;a list of recommended products&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuwy9i90rlokfob2rofju.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuwy9i90rlokfob2rofju.gif" alt="The iOS sheet component" width="442" height="856"&gt;&lt;/a&gt;&lt;/p&gt;
The iOS sheet component.



&lt;p&gt; &lt;/p&gt;

&lt;p&gt;The perfect place to look was the &lt;a href="https://vaul.emilkowal.ski/" rel="noopener noreferrer"&gt;Vaul&lt;/a&gt; library. Built by &lt;a href="https://emilkowal.ski/" rel="noopener noreferrer"&gt;Emil Kowalski&lt;/a&gt;, he took inspiration from the very same iOS sheet component I was tasked with replicating.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;shouldScaleBackground&lt;/code&gt; prop. When true, Vaul scales down the background and does a 3D translation of its position downwards.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb11w84o2ya1m8wvcm7bp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb11w84o2ya1m8wvcm7bp.png" alt="shouldScaleBackground in Vaul" width="800" height="146"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Looking for a quick fix, I initially tried promoting the to-be-scaled-down background to its own layer using &lt;code&gt;will-change: transform;&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;First, let’s lay a good foundation in some important concepts.&lt;/p&gt;

&lt;p&gt;A layer, simply, is a group of visual elements that share the same coordinate plane, as explained in more detail &lt;a href="https://webperf.tips/tip/layers-and-compositing/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the typical web page rendering pipeline, there are 5 areas: &lt;strong&gt;JS / CSS -&amp;gt; Style -&amp;gt; Layout -&amp;gt; Paint -&amp;gt; Composite&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feqjfq3bzb5lnfj6pq9kx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feqjfq3bzb5lnfj6pq9kx.png" alt="Rendering pipeline overview" width="800" height="122"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;a href="https://web.dev/articles/rendering-performance#the_pixel_pipeline" rel="noopener noreferrer"&gt;A simplified view of the rendering pipeline&lt;/a&gt;.



&lt;p&gt; &lt;/p&gt;

&lt;p&gt;The key area here is &lt;strong&gt;compositing&lt;/strong&gt;, 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). &lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;Compositor-Only Properties&lt;/strong&gt;. 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.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://web.dev/articles/stick-to-compositor-only-properties-and-manage-layer-count#manage_layers_and_avoid_layer_explosions" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fweb.dev%2Fstatic%2Farticles%2Fstick-to-compositor-only-properties-and-manage-layer-count%2Fimage%2Fthe-properties-can-anima-100ed2c7d26a4.jpg" height="600" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://web.dev/articles/stick-to-compositor-only-properties-and-manage-layer-count#manage_layers_and_avoid_layer_explosions" rel="noopener noreferrer" class="c-link"&gt;
            Stick to Compositor-Only Properties and Manage Layer Count  |  Articles  |  web.dev
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Compositing is where the painted parts of the page are put together for displaying on screen.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.gstatic.com%2Fdevrel-devsite%2Fprod%2Fva0c14339cfd6d9ab177114d5825fc3f29dc166d5e178822c1d1efe7d037760a4%2Fweb%2Fimages%2Ffavicon.png" width="33" height="32"&gt;
          web.dev
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;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 &lt;code&gt;transform: translateZ(0)&lt;/code&gt; property and in modern browsers through the &lt;code&gt;will-change: transform&lt;/code&gt; property.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://web.dev/articles/simplify-paint-complexity-and-reduce-paint-areas#promote_elements_that_move_or_fade" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fweb.dev%2Fstatic%2Farticles%2Fsimplify-paint-complexity-and-reduce-paint-areas%2Fimage%2Fa-representation-composi-c93c6e6c3367e.jpg" height="688" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://web.dev/articles/simplify-paint-complexity-and-reduce-paint-areas#promote_elements_that_move_or_fade" rel="noopener noreferrer" class="c-link"&gt;
            Simplify paint complexity and reduce paint areas  |  Articles  |  web.dev
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Paint is the process of filling in pixels that eventually get composited to the users&amp;amp;#39; screens. It is often the longest-running of all tasks in the pipeline, and one to avoid if at all possible.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.gstatic.com%2Fdevrel-devsite%2Fprod%2Fva0c14339cfd6d9ab177114d5825fc3f29dc166d5e178822c1d1efe7d037760a4%2Fweb%2Fimages%2Ffavicon.png" width="33" height="32"&gt;
          web.dev
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;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 &lt;a href="https://swiperjs.com/" rel="noopener noreferrer"&gt;Swiper&lt;/a&gt; library, of 3 slides.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Tobijudah" rel="noopener noreferrer"&gt;
        Tobijudah
      &lt;/a&gt; / &lt;a href="https://github.com/Tobijudah/vaul-iOS-perf-repro" rel="noopener noreferrer"&gt;
        vaul-iOS-perf-repro
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      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.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;vaul-ios-perf-repro&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Companion demo for the article &lt;strong&gt;"On web rendering, layers and compositing"&lt;/strong&gt; (&lt;a href="https://github.com/Tobijudah/vaul-iOS-perf-repro/./article%20%28wip%29/" rel="noopener noreferrer"&gt;article in progress&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;The article now documents this demo directly. The current write-up, screenshots, videos, and Android/iOS trial table are all based on this repo.&lt;/p&gt;
&lt;p&gt;The demo reproduces an iOS Safari crash that happens when &lt;a href="https://vaul.emilkowal.ski/" rel="nofollow noopener noreferrer"&gt;Vaul&lt;/a&gt;'s &lt;code&gt;shouldScaleBackground&lt;/code&gt; is combined with a long list of &lt;a href="https://swiperjs.com/" rel="nofollow noopener noreferrer"&gt;Swiper.js&lt;/a&gt; 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 &lt;strong&gt;all&lt;/strong&gt; those layers regardless of viewport position, and when the drawer opens, Vaul's background-scale animation forces a re-composite of the lot.&lt;/p&gt;
&lt;p&gt;In the current demo-based measurements used in the article:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS Safari starts at &lt;code&gt;641&lt;/code&gt; layers and &lt;code&gt;33.16 MB&lt;/code&gt;, then spikes to &lt;code&gt;646&lt;/code&gt; layers and &lt;code&gt;798.73 MB&lt;/code&gt; while opening the drawer.&lt;/li&gt;
&lt;li&gt;Chrome on Android also spikes during the animation (&lt;code&gt;391&lt;/code&gt; layers, &lt;code&gt;978&lt;/code&gt;…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Tobijudah/vaul-iOS-perf-repro" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;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?&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://webkit.org/web-inspector/layers-tab/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwebkit.org%2Fwp-content%2Fuploads%2Fweb-inspector_Layers_Tab_Light.png" height="548" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://webkit.org/web-inspector/layers-tab/" rel="noopener noreferrer" class="c-link"&gt;
              Layers Tab | WebKit
          &lt;/a&gt;
        &lt;/h2&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwebkit.org%2Ffavicon.ico" width="64" height="64"&gt;
          webkit.org
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://developer.chrome.com/docs/devtools/layers" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.gstatic.com%2Fdevrel-devsite%2Fprod%2Fva0c14339cfd6d9ab177114d5825fc3f29dc166d5e178822c1d1efe7d037760a4%2Fchrome%2Fimages%2Flockup.svg" height="745" class="m-0" width="5838"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://developer.chrome.com/docs/devtools/layers" rel="noopener noreferrer" class="c-link"&gt;
            Layers panel: Explore the layers of your website  |  Chrome DevTools  |  Chrome for Developers
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Inspect the layers that make up your website.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.gstatic.com%2Fdevrel-devsite%2Fprod%2Fva0c14339cfd6d9ab177114d5825fc3f29dc166d5e178822c1d1efe7d037760a4%2Fchrome%2Fimages%2Ffavicon.png" width="32" height="32"&gt;
          developer.chrome.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/rTEtbJ3IesM"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;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!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F54higmvd4h8s7yrtjkya.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F54higmvd4h8s7yrtjkya.png" alt="Safari Layers showing extra wrapper and parent layers" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Px99531m5lM"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Meanwhile, Safari rendered every single layer, regardless of whether it was in the viewport or how far away it was&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq3a7brcivf0wwavu1yo4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq3a7brcivf0wwavu1yo4.png" alt="Safari rendering the entire page worth of layers" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;
I could not even zoom out to show the entire page end to end, but it rendered it all.



&lt;p&gt; &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://segmentfault.com/a/1190000041197292/en" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;segmentfault.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;For those interested in the numbers, I did some limited testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  iOS
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Trial 1&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Phase&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Layers&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Memory (MB)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Notes&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Start&lt;/td&gt;
&lt;td&gt;641&lt;/td&gt;
&lt;td&gt;33.16&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open drawer (layout transform)&lt;/td&gt;
&lt;td&gt;646&lt;/td&gt;
&lt;td&gt;798.73&lt;/td&gt;
&lt;td&gt;Heavy stutter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stabilized (drawer open + transform done)&lt;/td&gt;
&lt;td&gt;645&lt;/td&gt;
&lt;td&gt;55.13&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Close drawer (spike during animation)&lt;/td&gt;
&lt;td&gt;646&lt;/td&gt;
&lt;td&gt;798.73&lt;/td&gt;
&lt;td&gt;Stutter and jank&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final (drawer closed + transform done)&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;Browser restarted the page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Trial 2&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Phase&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Layers&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Memory (MB)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Notes&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Start&lt;/td&gt;
&lt;td&gt;641&lt;/td&gt;
&lt;td&gt;33.16&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open drawer (layout transform)&lt;/td&gt;
&lt;td&gt;646&lt;/td&gt;
&lt;td&gt;798.73&lt;/td&gt;
&lt;td&gt;Heavy stutter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stabilized (drawer open + transform done)&lt;/td&gt;
&lt;td&gt;645&lt;/td&gt;
&lt;td&gt;55.13&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Close drawer (spike during animation)&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;Page crashed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Android
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Phase&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Layers&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Memory (MB)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Notes&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Start&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;292&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open drawer (spike)&lt;/td&gt;
&lt;td&gt;391&lt;/td&gt;
&lt;td&gt;978&lt;/td&gt;
&lt;td&gt;Very slight delay before otherwise smooth animation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stabilized open&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;559&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Close drawer (spike)&lt;/td&gt;
&lt;td&gt;391&lt;/td&gt;
&lt;td&gt;978&lt;/td&gt;
&lt;td&gt;Stutter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stabilized closed&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;292&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;A straightforward way of limiting how much off-screen content is rendered is through the &lt;code&gt;content-visibility: auto&lt;/code&gt;property. 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.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://web.dev/articles/content-visibility" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fweb.dev%2Fstatic%2Farticles%2Fcontent-visibility%2Fimage%2Fa-screenshot-a-travel-bl-d12bedce7d152.jpg" height="562" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://web.dev/articles/content-visibility" rel="noopener noreferrer" class="c-link"&gt;
            content-visibility: the new CSS property that boosts your rendering performance  |  Articles  |  web.dev
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            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.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.gstatic.com%2Fdevrel-devsite%2Fprod%2Fva0c14339cfd6d9ab177114d5825fc3f29dc166d5e178822c1d1efe7d037760a4%2Fweb%2Fimages%2Ffavicon.png" width="33" height="32"&gt;
          web.dev
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgaiurubhat2aci52ultj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgaiurubhat2aci52ultj.png" alt="content-visibility initial render" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;
With &lt;code&gt;content-visibility: auto&lt;/code&gt;, Safari starts much closer to what Chrome on Android was already doing by default.



&lt;p&gt; &lt;/p&gt;

&lt;p&gt;And when scrolling, it smartly only renders what is in view + a buffer, as Chrome on Android does by default&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/hshAGRlV7rY"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each swiper slide:

&lt;ul&gt;
&lt;li&gt;Element has "backface-visibility: hidden" style&lt;/li&gt;
&lt;li&gt;Element has a 3D transform&lt;/li&gt;
&lt;li&gt;Element may overlap another compositing element&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The wrapper element for the slides: Element has a 3D transform&lt;/li&gt;
&lt;li&gt;The main swiper parent element: Element clips compositing descendants&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebInspectorUI/UserInterface/Views/Layers3DContentView.js#L467" rel="noopener noreferrer"&gt;Layers3DContentView.js in WebKit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here is the equivalent list in Chromium:&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/blink/renderer/platform/graphics/compositing_reasons.h;l=18;drc=4e8e81f6eeb6969973f3ec97132d80339b92d227" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;source.chromium.org&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Starting with the slides themselves, I suspect the reason is that the &lt;code&gt;backface-visibility: hidden&lt;/code&gt; 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 &lt;code&gt;backface-visibility: hidden&lt;/code&gt; ensures that. Here’s a good demo from MDN.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/backface-visibility" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;developer.mozilla.org&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;backface-visibility&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;visible&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nt"&gt;-webkit-backface-visibility&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;visible&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Next, the &lt;em&gt;Element has a 3D transform&lt;/em&gt; reason is because the library set &lt;code&gt;transform: translateZ(0)&lt;/code&gt; 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 &lt;code&gt;transform: none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;


&lt;div class="crayons-card c-embed"&gt;

  
    👀 To verify this, I went digging into the WebKit source code…
  &lt;br&gt;

&lt;p&gt;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 &lt;a href="https://github.com/WebKit/WebKit/releases/tag/wpewebkit-2.51.1" rel="noopener noreferrer"&gt;&lt;strong&gt;WPE WebKit 2.51.1&lt;/strong&gt;&lt;/a&gt; released 25 October 2025.&lt;/p&gt;

&lt;p&gt;We can see the condition &lt;code&gt;compositingReasons.stacking&lt;/code&gt; (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebInspectorUI/UserInterface/Views/Layers3DContentView.js#L502" rel="noopener noreferrer"&gt;Layers3DContentView.js#L502&lt;/a&gt;) that returns the &lt;em&gt;Element may overlap another compositing element&lt;/em&gt; reason we're investigating. This is part of the compositing reason-assigning method I linked earlier (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebInspectorUI/UserInterface/Views/Layers3DContentView.js#L467" rel="noopener noreferrer"&gt;Layers3DContentView.js#L467&lt;/a&gt;) called &lt;code&gt;_updateReasonsList&lt;/code&gt;, defined as part of a &lt;code&gt;Layers3DContentView&lt;/code&gt; class.&lt;/p&gt;

&lt;p&gt;This method is called (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebInspectorUI/UserInterface/Views/Layers3DContentView.js#L462" rel="noopener noreferrer"&gt;Layers3DContentView.js#L462&lt;/a&gt;) in another method of that class called &lt;code&gt;_updateLayerInfoElement&lt;/code&gt; (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebInspectorUI/UserInterface/Views/Layers3DContentView.js#L447" rel="noopener noreferrer"&gt;Layers3DContentView.js#L447&lt;/a&gt;). The &lt;code&gt;WI.layerTreeManager.reasonsForCompositingLayer&lt;/code&gt; method in its second argument provides a callback with a &lt;code&gt;compositingReasons&lt;/code&gt; argument, which we pass to the &lt;code&gt;_updateReasonsList&lt;/code&gt; function.&lt;/p&gt;

&lt;p&gt;Tracing upwards from here we find where &lt;code&gt;layerTreeManager.reasonsForCompositingLayer&lt;/code&gt; is defined (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebInspectorUI/UserInterface/Controllers/LayerTreeManager.js#L190" rel="noopener noreferrer"&gt;LayerTreeManager.js#L190&lt;/a&gt;). It gets the reasons it passes to the callback function from another callback function &lt;code&gt;target.LayerTreeAgent.reasonsForCompositingLayer&lt;/code&gt; (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebInspectorUI/UserInterface/Controllers/LayerTreeManager.js#L190" rel="noopener noreferrer"&gt;LayerTreeManager.js#L190&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Continuing upwards, moving into C++ land now, we find the &lt;code&gt;LayerTreeAgent.reasonsForCompositingLayer&lt;/code&gt; method (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/inspector/agents/InspectorLayerTreeAgent.cpp#L246" rel="noopener noreferrer"&gt;InspectorLayerTreeAgent.cpp#L246&lt;/a&gt;) and inside it you can see (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/inspector/agents/InspectorLayerTreeAgent.cpp#L293" rel="noopener noreferrer"&gt;InspectorLayerTreeAgent.cpp#L293&lt;/a&gt;) where it sets stacking in compositorReasons to true when &lt;code&gt;reasons.contains(CompositingReason::Stacking)&lt;/code&gt; resolves to true. But where is &lt;code&gt;reasons&lt;/code&gt; in this context from? It's from &lt;code&gt;renderLayer-&amp;gt;compositor().reasonsForCompositing(*renderLayer)&lt;/code&gt; (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/inspector/agents/InspectorLayerTreeAgent.cpp#L253" rel="noopener noreferrer"&gt;InspectorLayerTreeAgent.cpp#L253&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;So next we find the &lt;code&gt;reasonsForCompositing&lt;/code&gt; method (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/rendering/RenderLayerCompositor.cpp#L3390" rel="noopener noreferrer"&gt;RenderLayerCompositor.cpp#L3390&lt;/a&gt;) and in it we can see where it adds the stacking reason to the compositing reasons based on if the &lt;code&gt;renderer.layer()-&amp;gt;indirectCompositingReason()&lt;/code&gt; method returns &lt;code&gt;IndirectCompositingReason::Stacking&lt;/code&gt; as the value.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;indirectCompositingReason&lt;/code&gt; is a getter function (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/rendering/RenderLayer.h#L941" rel="noopener noreferrer"&gt;RenderLayer.h#L941&lt;/a&gt;) that returns the value of &lt;code&gt;m_indirectCompositingReason&lt;/code&gt; which stores a &lt;code&gt;IndirectCompositingReason&lt;/code&gt;. And &lt;code&gt;setIndirectCompositingReason&lt;/code&gt; (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/rendering/RenderLayer.h#L1307" rel="noopener noreferrer"&gt;RenderLayer.h#L1307&lt;/a&gt;) is the setter function that is used to set the value of &lt;code&gt;m_indirectCompositingReason&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;So where is &lt;code&gt;setIndirectCompositingReason&lt;/code&gt; called and assigned to &lt;code&gt;IndirectCompositingReason::Stacking&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;First we have the &lt;code&gt;computeCompositingRequirements&lt;/code&gt; method (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/rendering/RenderLayerCompositor.cpp#L1260" rel="noopener noreferrer"&gt;RenderLayerCompositor.cpp#L1260&lt;/a&gt;) which assigns a variable &lt;code&gt;compositingReason&lt;/code&gt; to &lt;code&gt;IndirectCompositingReason::Stacking&lt;/code&gt; if the &lt;code&gt;compositingState.subtreeIsCompositing&lt;/code&gt; state is true (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/rendering/RenderLayerCompositor.cpp#L1290" rel="noopener noreferrer"&gt;RenderLayerCompositor.cpp#L1290&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Looking inside the RenderLayerCompositor class we see another method &lt;code&gt;updateWithDescendantStateAndLayer&lt;/code&gt; which assigns the &lt;code&gt;subtreeIsCompositing&lt;/code&gt; state to true if either a subtree is compositing or more importantly if the layer is currently composited (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/rendering/RenderLayerCompositor.cpp#L183" rel="noopener noreferrer"&gt;RenderLayerCompositor.cpp#L183&lt;/a&gt;). This will resolve to true in our case as the layer was composited for other reasons.&lt;/p&gt;

&lt;p&gt;Going back to the &lt;code&gt;computeCompositingRequirements&lt;/code&gt; method (&lt;a href="https://github.com/WebKit/WebKit/blob/9aea73f3d9fa2fa4ff6aca31d77139b77f89f7be/Source/WebCore/rendering/RenderLayerCompositor.cpp#L1338" rel="noopener noreferrer"&gt;RenderLayerCompositor.cpp#L1338&lt;/a&gt;) the &lt;code&gt;setIndirectCompositingReason&lt;/code&gt; setter function is eventually called with &lt;code&gt;compositingReason&lt;/code&gt; variable which was set earlier as its argument&lt;/p&gt;




&lt;/div&gt;



&lt;p&gt;Now our layer count is down to 257 and 261 when the modal is open, from 641 and 645 respectively.&lt;/p&gt;

&lt;p&gt;Moving to the next element, the swiper wrapper, we have the reason &lt;em&gt;Element has a 3D transform&lt;/em&gt; due to the &lt;code&gt;transform: translate3d(0px, 0, 0)&lt;/code&gt; style set on the element. In this case, we can't simply override it with &lt;code&gt;transform: none&lt;/code&gt;, 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 &lt;code&gt;onSlideChange&lt;/code&gt; prop. So we can override the library's transform with our own 2D version, using &lt;code&gt;translateX&lt;/code&gt; instead of &lt;code&gt;translate3d&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;onSlideChange&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;swiper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slideWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nx"&gt;swiper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;swiper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slidesPerView&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;swiper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slidesPerView&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;swiper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wrapperEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`translateX(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;
        &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;swiper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeIndex&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slideWidth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;productItemSwiperSpaceBetween&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px)`&lt;/span&gt;
&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;onInit&lt;/code&gt; prop to override it immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;onInit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;swiper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;swiper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wrapperEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;translateX(0px)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/0cqsL1IzGnA"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/bznSgSqqWK8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;

&lt;h2&gt;
  
  
  Other links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://medium.com/masmovil-engineering/layers-layers-layers-be-careful-6838d59c07fa" rel="noopener noreferrer"&gt;Layers, layers, layers... Be careful!&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://web.dev/articles/mobile-optimization-and-performance#hardware_acceleration" rel="noopener noreferrer"&gt;HTML5 techniques for optimizing mobile performance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stackoverflow.com/questions/17824060/ios-safari-memory-usage-with-webkit-transform" rel="noopener noreferrer"&gt;iOS Safari memory usage with "-webkit-transform"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.chrome.com/blog/inside-browser-part3#compositing" rel="noopener noreferrer"&gt;Inside look at modern web browser (part 3)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>css</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
