The last time I wrote here about the layer based SVG engine (LBSE) was back in autumn 2021, when I published the technical design document. A lot has happened since then, but maybe not what you would expect after almost four years. The engine landed in WebKit and it works well, but it is still not the default. You have to switch it on by toggling a runtime setting (in MiniBrowser). Between autumn 2021 and late 2022 LBSE was bootstrapped upstream, patch after patch. We added support for all the individual building blocks that make up SVG: paths, shapes, text, polygons, etc. within the new LBSE design. After that first big push the work stalled for a few months and resumed in mid-2023, and lasted until April 2024, due to generous support by Wix. During that period most advanced painting features, such as clipping, masking, filters, non-solid paint servers (patterns, gradients), markers, etc. were all implemented, sharing the logic with HTML and CSS rather than running through the separate code paths, as the legacy SVG engine did. Then the work paused again. A project of this size needs sustained funding to move forward, and for a while that funding was not there.
LBSE is promising, but it still has to earn its place, and it is worth being precise about what that means, because the goal was never simply “make SVG faster”. The legacy SVG engine is an island: historically grown, it has its own painting code, its own handling of transforms, clipping, masking, etc. and it shares only a little of the code that renders HTML and CSS for historical reasons. Many improvements that went into WebKit, went into “the other engine”, the main part, the HTML/CSS rendering engine. GPU-accelerated transforms and animations, accelerated compositing, all the work that made HTML/CSS fast. SVG sat right next to it and got none of it. That is what LBSE is really about. Put SVG on the same machinery, and it inherits all of that at once, and it keeps inheriting whatever comes next, instead of someone having to build it a second time for SVG alone.
The catch is that shared machinery is general machinery, and generality is not free. The legacy engine has been hand tuned for exactly one job, with two decades of tuning behind it. LBSE, on the other hand, has to integrate surgically into the shared HTML/CSS rendering code, without regressing that code even a little. This is the hottest code in the engine and it renders every web page out there, so sprinkling SVG special cases through layout and painting until LBSE looks good is simply not an option. Whatever LBSE needs either fits the existing design, or it has to make the existing design better for HTML and CSS too.
There is a second kind of work hiding in there as well. Some things that are rare in HTML are everywhere in SVG. Transformations are the obvious example. A typical web page has a handful of transformed elements, while in SVG almost every single element can carry a transform, and deeply nested transforms are the norm rather than the exception. The shared code was written with HTML in mind, and it does its job perfectly well there. So parts of the shared machinery have to be reworked, and fast paths that nobody ever needed for HTML have to be invented from scratch, without making anything slower for HTML in the process.
So the bar is a double one. LBSE first has to be competitive with the legacy engine on the plain, everyday rendering that legacy is already good at, because nobody switches engines if a static icon that never moves suddenly takes longer to paint. The rest, the hardware acceleration and every future improvement to the HTML/CSS engine that SVG now gets for free, only counts as a win once that first bar is met.
Fast forward to early 2026: This year the status quo finally changed. Igalia made a one-off investment into a fresh round of LBSE development, to give the engine the chance to prove itself. We are hopeful to finally show the world that LBSE is performant, and to attract new partners who share our vision that a performant, hardware-accelerated SVG engine is the future for UIs on embedded devices. This is the first of two posts covering the first half of 2026, where we stand now and how the LBSE project evolved since the long break in 2024. The last six months were intense, and ended with the single most invasive change to LBSE since it was first written. This post covers that change, and the next one picks up the hard problem it left behind.
A quick reminder of where we were
When we designed LBSE, the central idea was simple. Instead of keeping the old, separate SVG painting code, SVG
should reuse the very same machinery that HTML and CSS already use, the RenderLayer tree.
That is what unlocked hardware-accelerated transform animations, perspective transformations, z-index support - all the
nice things SVG never had before in WebKit.
To get there quickly, we took a shortcut in the early days. Every single SVG renderer got its own RenderLayer.
A <rect>, a <path>, a <g>, everything. The reason was convenience: if every element has a layer, then the
existing layer tree already knows how to order children, apply transforms, clip and composite. We did not have to
reinvent any of that. Furthermore, the intrinsic SVG paint order, which follows the DOM structure 1:1, is guaranteed
automatically this way, while z-index support is still there to deviate from that order when desired. All of that just
works out of the box once every renderer receives its own layer. RenderSVGModelObject::requiresLayer() simply returned true
all the time, and the rest of WebCore did the heavy lifting.
We knew from the start that this approach would most likely never ship in this form, because it is simply too wasteful. But that was fine. It was never meant to be the final design. What it gave us was a way to finally try out the things SVG had been missing for years, all running through the same machinery as HTML and CSS. It also gave us the possibility to reimplement all SVG render tree classes, in a way that’s fully unified between HTML and SVG, so that e.g. an SVG <clipPath> applied to an SVG element or an HTML element would no longer run through different code paths, as it historically did in WebKit. Thus just maintaining a 1:1 correspondence between renderers and layers was the fastest path to seeing the idea actually work, and once we had, the real shape of the engine could follow.
Correctness first
When we resumed work on LBSE, we first had to make sure the engine worked again properly after the long pause of upstream work. LBSE received no testing during that period, so it was likely to be in a broken state, and that turned out to be the case. Text rendering was initially fully broken, most testing baselines were outdated, many new regressions were present, where the legacy SVG engine received fixes but LBSE did not. It took us a while to recover.
Rob Buis, my partner in the endeavor to bootstrap LBSE, did a lot of this work, so I could focus on attacking the
remaining performance problems, profiling the engine, and trying new designs. We fixed a pile of crashes, for example
in getBBox on <use> elements, in
paths with NaN coordinates and line caps, and in
markers whose children change. We fixed real rendering bugs too,
including text not rendering at all,
text opacity,
gradients with a non-invertible gradientTransform, and
support for SVG view fragments.
Filters got a lot of attention through spring. We made invalid filters behave like they do for HTML instead of dropping the element entirely, fixed feOffset clipping, filter scaling, and a convolution filter test. The most important filter change, though, has not landed yet. Once conditional layer creation, the change the rest of this post is about, removes the intermediate layers, a filter’s coordinate space no longer lines up with what the painting code assumed, and a lot of filtered content ends up in the wrong place. A pending pull request corrects that for non-layered renderers and clears a large batch of filter test failures under LBSE once conditional layers are enabled.
None of this is glamorous, but it is the foundation. With the engine behaving well again, we could finally go after the thing this whole effort was about.
The real goal, performance
The point of LBSE was always performance. It was designed to unlock hardware-accelerated animations, which can potentially run much faster than in the legacy engine, since re-layouts and re-rasterization can be skipped entirely as the compositor is responsible for applying those transformations to the already-rasterized content. The catch is that scenarios which deliberately avoid that fast path suffer instead from the overhead of a layer per element. The benchmark we measure ourselves against here is MotionMark, and in particular its Suits subtest. Suits adds a large number of SVG primitives (rects and paths) that are clipped via <clipPath> elements, and animates them by modifying the transform attribute of each shape programmatically, in a requestAnimationFrame driven setup. It measures how many of them the browser can keep moving smoothly, which makes it a demanding, honest test of the SVG pipeline.
And here our early shortcut came back to bite us. Remember, every SVG renderer had its own RenderLayer. On a scene with thousands of shapes, that means thousands of layers. Each layer costs memory, and worse, each layer takes part in a lot of per-frame bookkeeping. Position updates, z-order lists, transform updates, repaint walks. None of these shapes should actually need a layer - a path/rect with a plain 2D transform is the most ordinary thing an SVG can contain. Yet we paid the full layer price for every one of them.
So the way forward was clear: stop giving every renderer a layer, and create one only when an element actually needs it. The first focus is transformed elements, the plain shapes that carry nothing but a 2D transform. Clipped elements keep their layer for now, and dropping that one too is future work. In Suits this first step already removes the layer from every transformed path, leaving only the clipped rects and the <svg> root, as the diagram below illustrates.
Suits builds a flat list of shapes directly under the <svg> root: alternating clipped <rect> elements and plain <path> elements, each with a gradient fill and a transform that is updated on every animation frame. A blue L marks a renderer that owns a RenderLayer. On the left every shape has one. On the right the plain transformed paths no longer do, while the clipped rects keep theirs for now, so only they and the <svg> root own a layer.
However, it turned out that we cannot easily remove layers for transformed containers, only for transformed leaf elements - we’ll get back to that later.
Conditional layer creation
Pulling layers out from under the whole engine is risky, but the real reason we did not do it in one jump is that it would have been a huge, unreviewable patch, since a lot of new concepts had to be invented first. A string of preparation patches had to land instead, each one dormant on its own, so that the eventual switch to conditional layers had solid ground to stand on.
The groundwork was mostly about finding a new home for the things that used to live on the layer. A RenderLayer
carried a pile of SVG-only fields that non-SVG layers never need, so we moved them into a lazily allocated SVGData
struct that only exists for layers within an LBSE subtree. That
was a good cleanup on its own, and everything else built on top of it, starting with the transform. We
used to cache an element’s local transform on its RenderLayer, and with no layer we needed a different place to cache
it, rather than recomputing it on every use. So we did what the legacy SVG engine had done all along and cached the transform on
the renderer itself, with a m_localTransform member and an
updateLocalTransform() method that plays the same role the layer version used to. A couple of follow-up patches
(311763@main and 315597@main) then made
sure dynamic transform updates work for both layered and non-layered SVG renderers.
With the SVG data and the transform moved into their new homes, we built a DOM-order paint path for non-layered SVG children, followed immediately by the matching DOM-order hit-test path. Behind both sits an SVG-specific, DOM-order collection of the renderable children, and it plays a crucial role in making conditional layer creation work at all.
HTML and SVG behave differently here. Take a block with three <div> elements where the middle one carries a CSS
transform. The transform makes that middle element establish its own stacking context, so it no longer paints in place.
The paint order becomes the first div, then the third, and only then the transformed middle one on top, assuming it has a
non-negative z-index. For SVG that would be wrong. SVG content has to paint in strict document order, whether or not a
layer is involved, so the middle child still paints between its two siblings. Adding z-index support so SVG can deviate
on purpose is a separate issue. None of this had to be solved by hand when every renderer had a layer and the layer tree
sorted everything for us. With conditional layers, and a mix of layered and non-layered children, we now have to
guarantee that document order ourselves.
Both paths were gated behind a check that still saw a layer on every renderer, so they had no effect yet and stayed
inactive until conditional layers turned them on. Next we
taught non-layered transformed content to position itself correctly inside the
paint recursion, folding the transform of a shape, image or piece of text into the coordinate system during painting. And
just before enabling conditional layers, we landed a large batch of tests for the
tricky mixed cases, so any regression from the coming change would surface right away. There is also an internal flag,
LayerBasedSVGEngineForceLayerCreationEnabled, that brings back the old behavior, and it was a great help for bisecting
problems, because it lets you compare the two worlds side by side.
With all of that in place, we could finally flip the switch. The headline change, conditional layer
creation, landed in June 2026. The idea is to create a RenderLayer only when an
element needs one for an intrinsic reason. Each renderer now implements a new method, requiresLayerForSVGIntrinsicReasons(),
and its return value alone decides whether that renderer gets a layer. An SVG element gets a layer only when some painting
effect needs the extra machinery a layer provides. That means opacity groups, clipping, masking, filters, blend modes, and
isolation. A 3D transform needs one too, along with a handful of related properties like perspective, preserve-3d, and
an explicit z-index. Everything else, the vast majority of shapes and groups, gets no layer at all.
Plain 2D transforms need a bit more care. On a leaf element like a shape or a piece of text, a 2D transform no longer forces a layer of its own. We store the transform on the renderer and fold it into the coordinate system at paint time, concatenating it onto the current transformation matrix just before the element draws. The exception is a transformed container, which still keeps its own layer. The compositing logic is built around the layer tree, not the render tree, and expects every transformed container to have one, because it computes a composited element’s current transformation matrix (CTM) by walking up the chain of ancestor layers and concatenating their transforms. A transformed container without a layer would drop out of that walk, so its transform would be lost and the composited descendant would be left with the wrong CTM. Reworking that expectation would be a substantial effort, so for now we settled on a compromise and keep a layer for every transformed container, which at least guarantees that an element’s ancestor chain transformation can be correctly computed.
With conditional layer creation in place, LBSE can now decide, renderer by renderer, whether a layer is needed at all, and create one only where an SVG renderer genuinely requires it. For non-composited content this is exactly what we wanted. The transform folds straight into the paint recursion, the shape draws in the right place, and no layer appears. Composited content, though, is now broken. As soon as a single composited element sits among plain, layer-less siblings, the strict SVG paint-order contract falls apart. The compositing code path can only order content that owns a layer, and now that conditional layer creation has taken layers away from most of the tree, it can no longer keep that composited element in its correct document-order slot.
We won the fast common case and lost the composited one in the same move, and winning it back without undoing all of this is the hard part the next post is about: the interesting compositing problem, and the design that finally solved it.
Let me close with a huge thanks to the Apple developers, involved in validating the LBSE work: thanks Simon, Said, Karl for the insightful discussions and input how to move LBSE forward! Thanks for reading until the end :-)