WebKit Bugzilla
New
Browse
Search+
Log In
×
Sign in with GitHub
or
Remember my login
Create Account
·
Forgot Password
Forgotten password account recovery
RESOLVED FIXED
312236
Page memory grows unboundedly when animating @property-registered custom properties consumed by calc() across many elements
https://bugs.webkit.org/show_bug.cgi?id=312236
Summary
Page memory grows unboundedly when animating @property-registered custom prop...
git
Reported
2026-04-13 23:16:04 PDT
Created
attachment 479059
[details]
saewitz.com memory debug json I have a complex CSS animation on my homepage that grows largely unbounded (100MB/s in memory). Chrome doesn't seem to climb above 90mb. Here's a "minimal" reproduction:
https://jsfiddle.net/6ybjf7mp/3/
Attached is the real-world debug json on desktop Safari. On webkit, this grows unbounded to several gb before triggering a late GC. On mobile safari, this crashes before any GC can happen. I apologize for the AI description, but I think it adequately captures the bug. You can verify yourself by running a performance test (with memory enabled) on my homepage or the jsfiddle, starting a recording, and then hovering over the pure CSS "globe". AI description below: ------------- A CSS animation that interpolates a single registered @property consumed by a moderate calc() chain on a few hundred elements causes Page memory to grow linearly at 100–200 MB/s during animation. Memory is reclaimable — a full collection eventually fires and reclaims nearly all of it — but the collector runs so infrequently (observed at ~1.6 GB on desktop Safari) that mobile Safari's per-tab memory cap is exceeded first, killing the tab. JavaScript heap, JIT cache, Images, and Layers categories remain flat throughout. All growth is in the Page category. Environment - macOS: Safari 18.x / STP (desktop trace) - iOS: Safari on iPhone (tab crash, reproducible within seconds of interaction) - No JavaScript running during the animation — pure CSS Steps to reproduce 1. Register a <angle>-typed custom property and 3+ <number>-typed custom properties via @property. 2. Create ~400 elements, each with unique inline <number> custom properties baked in (--bx, --by, --bz). 3. Add a CSS rule that composes a 6–7-deep calc() chain on all 400 elements, consuming the registered properties, producing a final transform and opacity. Include cos(), sin(), round(), sign(), and clamp() in the chain. 4. Animate the registered <angle> property via @keyframes from 0rad to 6.28318530718rad over ~14s, steps(540) for 30fps ticking. 5. Open Web Inspector → Timelines → record Memory. 6. Start the animation (hover target in our case) and observe the Page category. Expected result Page memory either: - stays approximately flat during the animation (values recycled per tick), or - grows to some bounded steady state and plateaus Actual result Page memory grows linearly at ~100–120 MB/s (desktop) or ~190 MB/s (mobile) for the entire duration of the animation. No incremental reclamation during idle frames. When the allocator eventually forces a collection, it reclaims ~1.4 GB in a single batch, and growth resumes at the identical slope. On mobile Safari, the tab is terminated by the OS well before WebKit's collector fires, because the 200–400 MB per-tab memory ceiling is reached in 1–4 seconds of animation. Additional data - Back-of-envelope allocation rate: 60 recalcs/s × ~400 elements ÷ 190 MB/s ≈ ~8 KB allocated per element-recalc. - Post-GC slope: After the batched collection, growth resumes at identical rate, confirming a rate-of-allocation problem, not a reference leak. - Disabling all @property registrations (leaving the properties as unregistered CSS variables, so animation falls back to the string-valued path) does not change the slope. The allocation is in calc-tree evaluation, not the Houdini typed-value machinery. - JS heap flat at ~29 MB throughout (±100 KB over 26 seconds). - Layers category: 0. No GPU layer promotion is occurring on the animated elements. - Images category: 0. - JIT category flat at ~0.5 MB. - will-change, contain, and transform-style: preserve-3d are all not in use. No layer promotion hints. Hypothesis Each style recalc during the @property animation allocates a tree of intermediate CSSCalcValue (or equivalent) nodes for each element that consumes the animated property. These intermediate nodes appear to be retained in the Page arena rather than released at the end of the recalc, accumulating until a full Page-level collection fires. The same accumulation pattern is present whether the driving property is registered or not, suggesting the retention is at the calc-evaluation layer rather than the registered-property layer. Minimal repro <!doctype html> <style> @property --angle { syntax: '<angle>'; inherits: true; initial-value: 0rad; } @property --bx { syntax: '<number>'; inherits: false; initial-value: 70; } @property --by { syntax: '<number>'; inherits: false; initial-value: 30; } @property --bz { syntax: '<number>'; inherits: false; initial-value: 50; } body { margin: 0; background: #111; } .stage { position: relative; width: 300px; height: 300px; margin: 40px auto; animation: spin 14s steps(540) infinite; } @keyframes spin { to { --angle: 6.28318530718rad; } } .dot { position: absolute; left: 150px; top: 150px; width: 5px; height: 5px; border-radius: 50%; background: #4ade80; --rx: calc(var(--bx) * cos(var(--angle)) + var(--bz) * sin(var(--angle))); --rz: calc(var(--bz) * cos(var(--angle)) - var(--bx) * sin(var(--angle))); --s-raw: calc(300 / (300 - var(--rz))); --s: round(var(--s-raw), 0.1); transform: translate(calc(var(--rx) * var(--s) * 1px), calc(var(--by) * var(--s) * 1px)) scale(calc(var(--s) * max(0, sign(var(--rz))))); opacity: calc(0.15 + 0.8 * clamp(0, (var(--rz) + 100) / 200, 1)); } /* Give dots varied base coordinates so calc results differ per element. */ .dot:nth-child(2n) { --bx: -40; --by: 50; --bz: -30; } .dot:nth-child(3n) { --bx: 20; --by: -60; --bz: 70; } .dot:nth-child(5n) { --bx: -80; --by: 10; --bz: -50; } .dot:nth-child(7n) { --bx: 60; --by: -20; --bz: 30; } .dot:nth-child(11n) { --bx: -10; --by: 80; --bz: -70; } </style> <div class="stage"> <!-- Repeat this line 400 times: --> <div class="dot"></div> <div class="dot"></div> <!-- ... --> </div> Open in Safari, record a memory timeline, observe Page category grow linearly. Impact The Houdini typed-custom-property feature is marketed precisely for this use case (native-speed interpolation without JS), so the current behavior undermines its primary value proposition.
Attachments
saewitz.com memory debug json
(91.85 MB, application/json)
2026-04-13 23:16 PDT
,
git
no flags
Details
Test
(31.78 KB, text/html)
2026-04-15 07:01 PDT
,
Antoine Quint
no flags
Details
minimal reproduction demo
(558 bytes, text/html)
2026-05-13 09:07 PDT
,
Pawel Lampe
no flags
Details
View All
Add attachment
proposed patch, testcase, etc.
Antoine Quint
Comment 1
2026-04-15 07:01:43 PDT
Created
attachment 479089
[details]
Test Attached the jsfiddle as a standalone HTML file.
Radar WebKit Bug Importer
Comment 2
2026-04-15 07:05:16 PDT
Comment hidden (obsolete)
<
rdar://problem/174832409
>
Antti Koivisto
Comment 3
2026-04-23 00:31:35 PDT
***
Bug 313108
has been marked as a duplicate of this bug. ***
Antti Koivisto
Comment 4
2026-04-23 00:35:05 PDT
rdar://171659746
Antti Koivisto
Comment 5
2026-04-23 00:36:21 PDT
Pull request:
https://github.com/WebKit/WebKit/pull/63404
EWS
Comment 6
2026-04-23 09:46:37 PDT
Committed
311862@main
(ebcf88c1bc30): <
https://commits.webkit.org/311862@main
> Reviewed commits have been landed. Closing PR #63404 and removing active labels.
EWS
Comment 7
2026-05-05 06:44:21 PDT
Committed
305877.473@webkitglib/2.52
(bd4109270ef1): <
https://commits.webkit.org/305877.473@webkitglib/2.52
> Reviewed commits have been landed. Closing PR #64254 and removing active labels.
Pawel Lampe
Comment 8
2026-05-13 09:07:44 PDT
Created
attachment 479654
[details]
minimal reproduction demo adding minimal demo in case someone would like to actually fix it
Note
You need to
log in
before you can comment on or make changes to this bug.
Top of Page
Format For Printing
XML
Clone This Bug