plans/PERFORMANCE_AUDIT.md
Generated by a multi-agent audit (8 dimensions, 47 findings, each adversarially verified by a code-accuracy lens and a browser-semantics/magnitude lens). Every claim is backed by file:line citations; impacts are the corrected impacts after verification downgrades.
x/scale/rotate). Today these idiomatic animations are NOT compositor-accelerated — they run on the JS main thread and rebuild + rewrite the whole element's styles every frame. Mapping them to a single composited transform WAAPI animation (as optimized-appear already does) moves the single most common animation case off the main thread. [HIGH]background-color/color WAAPI acceleration now that linear() exists. Color is deliberately excluded from acceleratedValues (a TODO pending Chromium 41491098 / linear()); plain color animations fall to per-frame JS. This is a main-thread offload (not free rendering — color is paint-bound), common in hover/theme transitions. [MEDIUM]will-change back to auto when animations complete. WillChangeMotionValue latches to "transform" and is never reset — a real latent bug. Low default impact only because auto-injection is dead-by-default (opt-in via useWillChange()), but a correctness fix worth landing. [LOW, corrected down from MEDIUM]correctBoxShadow runs complex.parse + complex.createTransformer — two full analyseComplexValue passes (the heaviest string op in the value system) per box-shadow element per frame during layout animations. One-line fix: derive both from a single parse. [MEDIUM, small effort]styleEffect the default render path for non-projected elements (structural). The granular effects architecture writes only changed keys vs the legacy whole-element rebuild. This is the strategic direction — but gated behind projection, feature-parity (transformTemplate/onUpdate/will-change), and benchmarking, since effects is currently dead-internally and unmeasured. [HIGH as architectural gate; per-frame win is LOW–MEDIUM and JS-path-only]Object.assign(element.style) / individual style writes — what does this mean for style recalc? Is there a more efficient way to target style changes?"The browser-CSSOM reality (verdict: the folk myth is FALSE).
Writing element.style.x = v (or setProperty) mutates the element's inline CSSStyleDeclaration and sets the document's style-dirty flag — but recalc and layout are deferred/batched. They do not fire per assignment. Recalc only happens when JS reads computed layout (getComputedStyle, offsetWidth, getBoundingClientRect) or at the next rendering step. Motion's write path (render.ts:10-25, buildHTMLStyles, buildTransform, applyProjectionStyles) performs zero layout reads, so all per-frame writes coalesce into at most one recalc per frame — not N recalcs (verified: render.ts:13-25 is pure writes; applyProjectionStyles at create-projection-node.ts:1993-2112 only writes targetStyle.*). The frameloop also enforces read-before-write structurally (batcher.ts:60 read step precedes :65 render step).
So the cost of "writing all styles" is redundant JS work + redundant CSSOM setter calls, NOT redundant recalcs. Concretely the wasted work is: object-key iteration, getValueAsType string conversions, the full buildTransform string concatenation, and the setter calls themselves.
Is there a more efficient write API? Verdict: NO — the write API is not the lever; change-granularity is.
cssText = '...' — worse, not better. It re-parses the entire declaration block (benchmarks show 15–50% slower for partial updates in Chrome/Safari), requires an additional JS string-concat pass Motion doesn't currently do (buildHTMLStyles populates an object, not a string), and clobbers projection's separate writes — render.ts:19 runs applyProjectionStyles after the style loop, writing transform/transformOrigin/opacity/visibility directly; cssText would fight these. [LOW — negative result]attributeStyleMap.set/CSSStyleValue) — only candidate that could shave per-write parse cost, but Motion already pre-stringifies numbers to typed strings (get-as-type.ts:6-9 → "0px"), negating the benefit unless Motion stopped stringifying and passed CSSUnitValue through — a large change with uneven browser support. needs-measurement before pursuing.--x) — no reduction in writes; adds indirection.setProperty(dashed) — negligible (sub-microsecond); the split is dictated by correctness (--vars require setProperty; element.style['--x'] is a no-op). Leave as-is. [LOW]The real lever: write only the keys that changed. That is exactly what the effects path does (effects/style/index.ts:55-59 — one closure per key, scheduled only on that key's change via MotionValueState.ts:37). The savings are JS work, not avoided recalcs.
Verdict: It is OK for the dominant case, and overstated as a problem — but the legacy whole-element rebuild is genuinely wasteful on the JS animation path.
The critical scoping fact: for the default <motion.div> animating opacity (and literal transform/filter/clipPath strings), the per-frame rebuild does not run at all. Those route through WAAPI (AsyncMotionValueAnimation.ts:170-188 → NativeAnimationExtended), which only touches the MotionValue on finish/interrupt (NativeAnimation.ts:105-106, 162-163), never per frame. The compositor interpolates off-main-thread; buildHTMLStyles/renderHTML are dormant.
Where the rebuild DOES run (the JS path): transform shorthands x/scale/rotate (NOT in acceleratedValues — see myths below), color, width/height/top/left, springs on non-accelerated props, drag/inertia, onUpdate present, transformTemplate, SVG, layout/projection. On this path, any single value change re-iterates ALL latestValues (build-styles.ts:27), re-runs getValueAsType per key, rebuilds the full transform string (build-transform.ts), and re-writes every style key + var (render.ts:13-25) — once per element per frame (deduped via renderScheduledAt guard + Set, so once-per-element not once-per-value).
The quantified win of styleEffect: legacy cost scales with total tracked keys per element per frame; effects scales with changed keys. The win is primarily avoided JS (full latestValues iteration, per-key getValueAsType, full transform-string rebuild for unchanged keys), secondarily fewer CSSOM writes. It is strictly better on the JS path, parity on transform-only (effects funnels all transform props through one shared computed closure — style/index.ts:34-39 — so ~1 transform render either way), and zero difference on the WAAPI path (neither runs).
Corrected impact: MEDIUM on the JS path, LOW for typical accelerated animations. Not "the single highest-value change" — it never fires on the hot path most apps use. Benchmark with a NON-accelerated property (width, color, or a CSS var) — measuring opacity/transform-string will show no delta because that work never ran.
| Finding | Mechanism (1 line) | Impact | Effort | Recommendation |
|---|---|---|---|---|
Transform shorthands (x/scale/rotate) stay on JS path | acceleratedValues lacks shorthands; name="x" → JSAnimation → per-frame rebuild | HIGH | Large | Map shorthands to one composited transform WAAPI animation |
| Projection blocks wholesale styleEffect default | applyProjectionStyles owns/overwrites transform+origin, reads latestValues wholesale (render.ts:19) | HIGH | Large | Gate styleEffect default on projection-inactive subtree |
| Default opacity/literal-transform runs on compositor | WAAPI; no per-frame JS or style writes | HIGH (positive) | Small | Prioritize widening WAAPI over cheapening per-frame writes |
Springs/easings accelerate via linear() | applyToOptions→keyframes→generateLinearEasing | MEDIUM (corrected from HIGH; Safari de-accelerates linear()) | Small | Don't assume "spring ⇒ JS" |
background-color/color excluded from acceleration | accelerated-values.ts comments it out; plain colors → JSAnimation | MEDIUM | Medium | Re-enable (offload, not free render); fix backgroundColor naming |
| Box-shadow scale-correction double-parses | complex.parse + complex.createTransformer = 2× analyseComplexValue/frame | MEDIUM | Small | Parse once; cache by source string |
onUpdate prop is an element-wide WAAPI kill-switch | waapi.ts:51,68 reads component prop → all values to JS | LOW (corrected from MEDIUM) | Medium | Scope opt-out per-value; or document |
| Legacy full-rebuild on any change (no dirty tracking) | triggerBuild→buildHTMLStyles over all latestValues | MEDIUM | Large | Per-key dirty set, or port to effects |
buildTransform full rebuild every frame | 17-slot loop + new string even when one transform changed | MEDIUM | Medium | Dirty-flag gate; drop .trim() alloc |
buildTransform/color/complex per-frame string allocs | Template literals allocate; transform/rgba/complex strings rebuilt | MEDIUM (transform) / LOW (color/complex) | Small–Med | Skip re-conversion of unchanged values |
Projection willUpdate() sync getBoundingClientRect in commit | MeasureLayout.tsx:98 in getSnapshotBeforeUpdate | MEDIUM | Medium | Inherent to FLIP; keep reads grouped |
| Standalone (no-owner) MotionValues always JS | waapi.ts:39-49 !(subject instanceof HTMLElement) | MEDIUM | Medium | Hard constraint; per-frame opt matters most here |
| Effects is dead-internally / unmeasured | Exported (index.ts:66-69), consumed by no component | MEDIUM | Medium | Wire behind flag + benchmark before default |
| Effects missing transformTemplate/onUpdate/will-change parity | transform.ts no template; MotionValueState no hooks | LOW (corrected from HIGH; migration checklist, not runtime) | Large | Parity checklist before default |
will-change latches to transform forever | No isEnabled=false path anywhere | LOW (corrected from MEDIUM; dead-by-default) | Medium | Ref-count + debounced reset to auto |
| renderHTML rewrites all keys/vars (CSSOM, not recalc) | render.ts:13-25 unconditional | LOW | Medium | Granular path; do NOT justify as "N recalcs" |
getValueAsType/unchanged-value re-conversion | Re-converts every key every frame | LOW (corrected from MEDIUM) | Medium | Bounded to tracked/target keys, not static CSS |
| Effects adds N callbacks for N independent non-transform keys | N Set entries vs legacy's 1 | LOW | Small | Accept; cheaper than avoided rebuild |
3× time.now() per set | Microtask-cached; only 1st is real performance.now() | LOW | Small | Thread currentTime into setCurrent |
dirty() fan-out re-notifies unconditionally | No current!==prev guard | LOW (corrected from MEDIUM; not used by useTransform/useSpring) | Large | Add guard; benign today |
| WAAPI-interrupt double-sample | One-time JSAnimation construct + 2 samples | LOW | Small | Bounded to interruptions; leave |
Scroll trackContentSize keepAlive read | Per-frame scrollWidth/Height, keeps rAF alive | LOW | Medium | ResizeObserver on content (non-trivial) |
| SubscriptionManager array-backed | O(n) subscribe; notify is copy-free | LOW | Medium | Leave unless mount churn profiles hot |
| camelCase vs setProperty | Same CSSOM mutation; split is correctness | LOW | Small | Leave as-is |
SVG transform-box:fill-box injection | One-time per SVG element; comment is backwards | LOW | Small | Fix comment "If this is an SVG element" |
| Frameloop/subscription/tick objects pooled | Sets swapped, generator state reused | LOW (positive) | Small | No action — redirect effort away |
x/scale/rotate) run on the JS main thread, not the compositoracceleratedValues (accelerated-values.ts:4-12) contains only opacity, clipPath, filter, transform — the literal strings. animateTarget passes the raw key as name (visual-element-target.ts:73,140), so name="x". supportsBrowserAnimation gates on acceleratedValues.has(name) (waapi.ts:60), which is false for x/scale/rotate → AsyncMotionValueAnimation picks JSAnimation (AsyncMotionValueAnimation.ts:170-188). The JS tick then drives value.set() → change handler → scheduleRender → buildHTMLStyles + buildTransform + renderHTML every frame.accelerated-values.ts:4-12; visual-element-target.ts:73,140; waapi.ts:60; AsyncMotionValueAnimation.ts:170-188; JSAnimation.ts:344-345. Motion's own docs confirm: "you can't animate these properties with WAAPI." Shorthands are only remapped to transform in the opt-in optimized-appear path (store-id.ts:4).elements × frames.transform WAAPI animation (the optimized-appear path proves it's feasible).transformTemplate users, and projection-owned transforms must be excluded.renderHTML calls applyProjectionStyles(elementStyle, styleProp) after writing styles (render.ts:19). It overwrites targetStyle.transform = buildProjectionTransform(...) and transformOrigin from the projection delta, reading lead.animationValues || lead.latestValues wholesale — not a MotionValueState. The effects transform closure writes element.style.transform = buildTransform(state) (style/index.ts:35) with zero projection awareness. There is no hook for projection to compose the final transform in the granular model. Scale-correction (scaleCorrectors) also writes border-radius/box-shadow in the same render.render.ts:19; create-projection-node.ts (motion-dom path) :2044-2048, 2054, 2057-2059, scale-correctors :2092-2125; effects/style/index.ts:34-36. buildProjectionTransform interleaves projection delta + treeScale with user rotate/skew — even a non-projecting child of a scaled ancestor needs projection-aware composition.VisualElement.render — they need wholesale recompute anyway (transform depends on per-frame box measurements, so it's not waste).animateMotionValue → AsyncMotionValueAnimation → useWaapi = canAnimateValue && !isHandoff && supportsBrowserAnimation(...) → NativeAnimationExtended (WAAPI). The MotionValue is touched only on onfinish and stop() — no per-frame callback. buildHTMLStyles/renderHTML never execute during the animation.interfaces/motion-value.ts:138-140; AsyncMotionValueAnimation.ts:170-188; NativeAnimation.ts:95-123, 162-163; eligibility in waapi.ts:47-73.filter/clipPath are accelerated but paint-bound (still repaint each frame). Excluded: SVG, onUpdate, transformTemplate (transform), handoff, layout-driven transforms.buildHTMLStyles/renderHTML only pays off for JS-path elements. Prioritize widening WAAPI eligibility over making per-frame writes cheaper.background-color/color deliberately excluded from accelerationacceleratedValues comments out background-color (TODO citing Chromium 41491098 / linear()). supportsBrowserAnimation only force-accelerates color when keyframes contain browser-only formats (oklch/lab/etc. via hasBrowserOnlyColors). Plain #fff → #000 fails both branches → JSAnimation → per-frame value.set() → full rebuild.accelerated-values.ts:9-11; waapi.ts:60-62; is-browser-color.ts:1-14.elements × frames (not × values).background-color is paint-bound, not compositor-only — even on WAAPI it repaints every frame. The win is main-thread offload (removes JS tick + color mix + buildHTMLStyles iteration + CSSOM writes), NOT "free rendering." Reframe accordingly.name is camelCase backgroundColor (VisualElement.ts acceleratedValues.has(key)), but the commented entry is hyphenated background-color. Simply uncommenting it would NOT work — it must be backgroundColor. Verify Chromium 41491098 status before shipping; keep the JS fallback for non-Chrome.applyProjectionStyles iterates scaleCorrectors every frame; correctBoxShadow calls both complex.parse(latest) and complex.createTransformer(latest), each running analyseComplexValue (the large complexRegex over the whole shadow string + color.parse on the token). The shadow is fully tokenized twice, then re-serialized.create-projection-node.ts:2092-2106; scale-box-shadow.ts:8,13; complex/index.ts:82-84, 108-110, 60. By contrast borderRadius correction is cheap (scale-border-radius.ts outputs percentages, avoiding repaints).buildTransformer already accepts a ComplexValueInfo; call analyseComplexValue(latest) once and derive both.latestValues[key]=v; VisualElement.ts:578-586) but whole-element OUT: render() → triggerBuild() → buildHTMLStyles loops all latestValues, then renderHTML rewrites every key + var. No record of which key changed. Deduped to once-per-element-per-frame (Set + renderScheduledAt), not once-per-value.VisualElement.ts:578-586, 670-679, 682-688; build-styles.ts:27; render.ts:13-25. Cost is over latestValues keys (tracked motion values + css vars), NOT arbitrary static CSS (which goes through React props and is never re-iterated).tracked-keys × elements × frames. Redundant CSSOM rewrites of unchanged values do NOT cause extra recalc (Blink no-ops identical inline writes) — the waste is JS.buildTransform rebuilds the full transform string every framelatestValues key is a transform prop, buildTransform loops all 17 transformPropOrder slots, does default-check + getValueAsType per present value, and concatenates a fresh string. Animating x alone re-serializes scale/rotate/etc.; an opacity-only frame on a transformed element still re-enters and rewrites style.transform.build-styles.ts:30-33, 52-58; build-transform.ts:36, 56, 77 (the .trim() allocates an extra string). numTransforms = transformPropOrder.length = 17 (keys-transform.ts:4-22); pathRotation is composed separately (:68-75), not in the loop.projection.isTransformDirty gate is only set when this.projection exists (VisualElement.ts:582) — a projection-independent dirty flag is needed. Effects fixes opacity-only/non-transform redundancy but still re-serializes ALL transform slots when any one changes.buildTransform concatenation + getValueAsType produce fresh "Npx"/"Ndeg" strings each frame (template literals always allocate non-interned strings in V8). Color animations build a fresh rgba(...) string via rgba.transform (the mixer correctly reuses the blended object — color.ts:52 — only the final string allocates). Complex values (filter/box-shadow) rebuild a long string via buildTransformer (the mixArray output array is also reused — complex.ts:45).build-transform.ts:29,51,56,77; get-as-type.ts:6-9; units.ts:8; color.ts:52,59; rgba.ts:16-25; complex/index.ts:89-97.willUpdate() does synchronous getBoundingClientRect in React commitMeasureLayout.getSnapshotBeforeUpdate calls projection.willUpdate() → updateSnapshot() → measure() → measureViewportBox() → getBoundingClientRect(). This runs in React's commit (pre-mutation) phase, outside Motion's frameloop read step. All sibling willUpdate reads are grouped before React's writes (safe — not A/B/A/B thrash), but it's N synchronous forced layouts if anything dirtied layout pre-commit.MeasureLayout.tsx:98 (inside getSnapshotBeforeUpdate :68); create-projection-node.ts:718, 888, 1024-1028; measure.ts:13. getBoundingClientRect forces recalc only if layout was invalidated since last layout; consecutive reads with no intervening write coalesce.cancelTreeOptimisedTransformAnimations (create-projection-node.ts:680) is a DOM write inside willUpdate before the snapshot — maintainer-flagged in-code (:670-674). Scope is narrow (optimised-appear only, first willUpdate).supportsBrowserAnimation returns false if !(subject instanceof HTMLElement) where subject = motionValue?.owner?.current. A bare useMotionValue() (a useTransform/useScroll source) has no DOM owner → JS animation → per-frame mv.set() → notifies dependents → transform chain on main thread.waapi.ts:39-49; value/index.ts:366-374. Correct browser constraint (no element to hand to element.animate).useTransform source won't update its dependents mid-animation (correctness nuance, verify in browser). Scroll-linked values can still be compositor-accelerated via ScrollTimeline when driving an accelerated property.styleEffect/svgEffect/addStyleValue/MotionValueState are exported (index.ts:66-69) and re-exported as public API (framer-motion/dom, motion), but no internal consumer — the React component, animate(), and scope() all still go through VisualElement (animate/subject.ts:132-138). The granular win is currently theoretical and unmeasured against the production pipeline.index.ts:66-69; grep finds zero internal consumers; subject.ts:132-138 uses createDOMVisualElement. (It IS exercised by Playwright fixtures dev/html/public/playwright/effects/* and a benchmark stress-style-effect.html — so "no integration" is narrowly about the component, not "dead code.")styleEffect behind the motion component for the no-projection/no-feature case behind a flag; (2) benchmark multi-style + many-tracked-keys scenarios vs VisualElement; (3) measure multi-independent-key callback overhead. Ship opt-in fast-path first.linear()supportsBrowserAnimation does not reject springs/function easings. spring.applyToOptions converts the spring to type:"keyframes" + sampled ease; mapEasingToNativeEasing compiles that to a linear() string. The default transform transition IS a spring (default-transitions.ts:41-44), so the default transform animation accelerates — when it's a literal transform/opacity, and when the browser composites linear().apply-generator.ts:9-10; spring.ts:448-455; map-easing.ts:13-16; default-transitions.ts:41-44.linear() → degrades to ease-out (still WAAPI); (b) Safari parses linear() but does NOT compositor-accelerate animations using it — they run main-thread, so the default transform spring is main-thread in Safari; (c) springs on non-accelerated props; (d) handoff/optimised-appear/non-animatable keyframes always use JSAnimation.| Assumption tested | Verdict |
|---|---|
"Each element.style.x= write triggers a synchronous style recalc" | FALSE — writes are batched/lazy; recalc deferred until a layout read. No reads in Motion's write path → ≤1 recalc/frame. |
| "ANY single value change re-iterates and re-writes ALL styles that frame" | TRUE but scoped — true once-per-element-per-frame on the JS path; deduped (not per-value); does NOT run for WAAPI-accelerated values. |
| "Re-writing an identical value is free" | FALSE-ish — setter+parse run, but Blink fast-paths identical inline writes (no invalidation). Waste is JS only. |
"cssText (single parse) is cheaper" | FALSE — 15–50% slower for partial updates; needs extra JS concat; clobbers projection writes. |
| "Typed OM would speed up writes" | OVERSTATED — negated by Motion's pre-stringification; needs-measurement, large change. |
| "Default opacity/transform animation re-writes all styles every frame" | FALSE for opacity & literal transform (WAAPI). TRUE for x/scale/rotate shorthands (JS path). |
| "Springs/custom easing/>2 keyframes force the JS path" | FALSE — compile to linear()/per-keyframe easing arrays; accelerate (except Safari-linear, non-accelerated props, handoff). |
"onUpdate only affects the value it reports on" | FALSE — element-wide WAAPI kill-switch (reads component prop). |
"A MotionValue read by useTransform forces JS" | FALSE — it's having no HTMLElement owner that forces JS, not being read. |
"The effects path is the partial production path for animate()/scope()" | FALSE — all go through VisualElement; effects has no internal consumer. |
| "Granular effects = strictly more per-frame callbacks" | OVERSTATED — transform funnels through one shared closure (parity); extra callbacks only for N independent non-transform keys, and tiny. |
| "Will-change is applied too broadly / too narrowly" | NEITHER by default — auto-injection is dead-by-default; the real defect is it never resets. |
"Motion adds translateZ(0)/3D to force GPU layers" | FALSE — no synthetic GPU hint anywhere (a dead enableHardwareAcceleration type with a stale comment exists, consumed nowhere). |
"dirty() fan-out scales propagation through useTransform/useSpring chains" | FALSE — dependents/dirty() is used ONLY by the effects transform/transformOrigin path (depth 1, fan-out ~1); useTransform/useSpring use guarded on("change")+set(). |
"Redundant time.now() per change is costly" | FALSE — microtask-cached; ≤1 real performance.now()/frame (zero during frame processing). |
"SubscriptionManager.notify copies the subscriber list" | FALSE — reads length once, indexes in place, single-subscriber fast path. |
| "Projection reads layout then writes transforms — is measurement batched?" | YES — tree update is write→read→write batched (measure(false) skips the removeTransform read-cascade); ≤1 recalc/flush. |
| "Keyframe resolution reads computed styles mid-animation" | FALSE — reads at animation START only, cross-element batched W/R/W/R; the per-frame tick does NO layout reads. |
cssText for fewer CSSOM ops." Slower, needs extra JS, and conflicts with projection's post-loop writes. Don't.setProperty is a perf choice worth refactoring." Sub-microsecond; dictated by correctness (vars need setProperty). Leave as-is.mix() closures per tick / SubscriptionManager array copies are GC hotspots." All explicitly pooled/reused (Sets swapped render-step.ts:13-14,89-91; mixers built once interpolate.ts:90; notify is copy-free). No action.blended object and mixArray output are reused; only the irreducible final string allocates. Already good.translateZ(0). False worry.getBoundingClientRect into notifyLayoutUpdate/resolveTargetDelta/calcProjection.correctBoxShadow — one-line change, removes ~50% of the per-frame correction parse during layout animations. [MEDIUM]effects/style/index.ts:23). [LOW, correctness].trim() allocation in buildTransform (build-transform.ts:77) — append without trailing space. [LOW]currentTime from updateAndNotify into setCurrent (make the param optional for the constructor path). [LOW]getBoundingClientRect. [LOW]x/scale/rotate → one composited transform animation). The single biggest real-world per-frame JS reduction. [HIGH]background-color WAAPI (fix the backgroundColor naming, verify Chromium 41491098, frame as offload). [MEDIUM]transformTemplate into effects buildTransform, add an onUpdate notification path in MotionValueState, wire will-change; (3) keep VisualElement.render for projected elements. [HIGH as gate; LOW–MEDIUM per-frame win, JS-path only]auto via ref-counting active accelerated values + debounced removal. [LOW, latent bug]trackContentSize → ResizeObserver — non-trivial (must observe content, not container; page-scroll case has no stable content element).