Back to Motion

Motion Performance Audit — Style Writes, Per-Frame Cost, and the styleEffect Migration

plans/PERFORMANCE_AUDIT.md

12.41.034.8 KB
Original Source

Motion Performance Audit — Style Writes, Per-Frame Cost, and the styleEffect Migration

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.

Executive Summary — Top 5 Highest-Leverage Changes

  1. Widen WAAPI eligibility for transform shorthands (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]
  2. Enable 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]
  3. Reset 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]
  4. Parse box-shadow once in projection scale-correction. 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]
  5. Make 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]

Answering the Two Framing Questions

(a) "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 framenot 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 writesrender.ts:19 runs applyProjectionStyles after the style loop, writing transform/transformOrigin/opacity/visibility directly; cssText would fight these. [LOW — negative result]
  • CSS Typed OM (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.
  • Routing transform through a single CSS custom property (--x) — no reduction in writes; adds indirection.
  • camelCase vs 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]
  • Re-writing an unchanged value — not free (setter + parse run), but Blink fast-paths identical inline declarations and skips invalidation, so it does NOT dirty computed style or expand the recalc surface. The only waste is the JS setter round-trip. [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.

(b) "We write all styles every animation frame — is this OK vs the styleEffect approach?"

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-188NativeAnimationExtended), 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.


Ranked Findings Table (by corrected impact, then confidence/effort)

FindingMechanism (1 line)ImpactEffortRecommendation
Transform shorthands (x/scale/rotate) stay on JS pathacceleratedValues lacks shorthands; name="x" → JSAnimation → per-frame rebuildHIGHLargeMap shorthands to one composited transform WAAPI animation
Projection blocks wholesale styleEffect defaultapplyProjectionStyles owns/overwrites transform+origin, reads latestValues wholesale (render.ts:19)HIGHLargeGate styleEffect default on projection-inactive subtree
Default opacity/literal-transform runs on compositorWAAPI; no per-frame JS or style writesHIGH (positive)SmallPrioritize widening WAAPI over cheapening per-frame writes
Springs/easings accelerate via linear()applyToOptions→keyframes→generateLinearEasingMEDIUM (corrected from HIGH; Safari de-accelerates linear())SmallDon't assume "spring ⇒ JS"
background-color/color excluded from accelerationaccelerated-values.ts comments it out; plain colors → JSAnimationMEDIUMMediumRe-enable (offload, not free render); fix backgroundColor naming
Box-shadow scale-correction double-parsescomplex.parse + complex.createTransformer = 2× analyseComplexValue/frameMEDIUMSmallParse once; cache by source string
onUpdate prop is an element-wide WAAPI kill-switchwaapi.ts:51,68 reads component prop → all values to JSLOW (corrected from MEDIUM)MediumScope opt-out per-value; or document
Legacy full-rebuild on any change (no dirty tracking)triggerBuildbuildHTMLStyles over all latestValuesMEDIUMLargePer-key dirty set, or port to effects
buildTransform full rebuild every frame17-slot loop + new string even when one transform changedMEDIUMMediumDirty-flag gate; drop .trim() alloc
buildTransform/color/complex per-frame string allocsTemplate literals allocate; transform/rgba/complex strings rebuiltMEDIUM (transform) / LOW (color/complex)Small–MedSkip re-conversion of unchanged values
Projection willUpdate() sync getBoundingClientRect in commitMeasureLayout.tsx:98 in getSnapshotBeforeUpdateMEDIUMMediumInherent to FLIP; keep reads grouped
Standalone (no-owner) MotionValues always JSwaapi.ts:39-49 !(subject instanceof HTMLElement)MEDIUMMediumHard constraint; per-frame opt matters most here
Effects is dead-internally / unmeasuredExported (index.ts:66-69), consumed by no componentMEDIUMMediumWire behind flag + benchmark before default
Effects missing transformTemplate/onUpdate/will-change paritytransform.ts no template; MotionValueState no hooksLOW (corrected from HIGH; migration checklist, not runtime)LargeParity checklist before default
will-change latches to transform foreverNo isEnabled=false path anywhereLOW (corrected from MEDIUM; dead-by-default)MediumRef-count + debounced reset to auto
renderHTML rewrites all keys/vars (CSSOM, not recalc)render.ts:13-25 unconditionalLOWMediumGranular path; do NOT justify as "N recalcs"
getValueAsType/unchanged-value re-conversionRe-converts every key every frameLOW (corrected from MEDIUM)MediumBounded to tracked/target keys, not static CSS
Effects adds N callbacks for N independent non-transform keysN Set entries vs legacy's 1LOWSmallAccept; cheaper than avoided rebuild
time.now() per setMicrotask-cached; only 1st is real performance.now()LOWSmallThread currentTime into setCurrent
dirty() fan-out re-notifies unconditionallyNo current!==prev guardLOW (corrected from MEDIUM; not used by useTransform/useSpring)LargeAdd guard; benign today
WAAPI-interrupt double-sampleOne-time JSAnimation construct + 2 samplesLOWSmallBounded to interruptions; leave
Scroll trackContentSize keepAlive readPer-frame scrollWidth/Height, keeps rAF aliveLOWMediumResizeObserver on content (non-trivial)
SubscriptionManager array-backedO(n) subscribe; notify is copy-freeLOWMediumLeave unless mount churn profiles hot
camelCase vs setPropertySame CSSOM mutation; split is correctnessLOWSmallLeave as-is
SVG transform-box:fill-box injectionOne-time per SVG element; comment is backwardsLOWSmallFix comment "If this is an SVG element"
Frameloop/subscription/tick objects pooledSets swapped, generator state reusedLOW (positive)SmallNo action — redirect effort away

HIGH & MEDIUM Findings — Detail

[HIGH] Transform shorthands (x/scale/rotate) run on the JS main thread, not the compositor

  • Mechanism. acceleratedValues (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/rotateAsyncMotionValueAnimation picks JSAnimation (AsyncMotionValueAnimation.ts:170-188). The JS tick then drives value.set() → change handler → scheduleRenderbuildHTMLStyles + buildTransform + renderHTML every frame.
  • Evidence. 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).
  • Expected impact + scaling. This is the most common Motion animation. Per element × per changed value × per frame on the main thread. Scales with elements × frames.
  • Effort. Large — requires composing multiple shorthand sub-animations into one transform WAAPI animation (the optimized-appear path proves it's feasible).
  • Risk. Composition semantics (independent timing/easing per sub-value), transformTemplate users, and projection-owned transforms must be excluded.

[HIGH] Projection is the hard blocker to making styleEffect the default

  • Mechanism. When a projection node is active, 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.
  • Evidence. 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.
  • Expected impact + scaling. One-time architectural cost, but gates whether the migration is wholesale or fast-path-only.
  • Effort. Large.
  • Risk. Misjudging the subtree gate (must be subtree-scoped, not element-scoped, because of treeScale propagation).
  • Recommendation. Make styleEffect the default ONLY when the projection subtree is inactive (no layout/layoutId, no scale-correctors, no projecting ancestor contributing treeScale). Projected elements keep VisualElement.render — they need wholesale recompute anyway (transform depends on per-frame box measurements, so it's not waste).

[HIGH, positive] Default opacity/literal-transform animation is fully compositor-accelerated

  • Mechanism. animateMotionValueAsyncMotionValueAnimationuseWaapi = 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.
  • Evidence. interfaces/motion-value.ts:138-140; AsyncMotionValueAnimation.ts:170-188; NativeAnimation.ts:95-123, 162-163; eligibility in waapi.ts:47-73.
  • Caveats. "No style writes" is true per-frame; there's O(1) work at finish/interrupt boundaries. filter/clipPath are accelerated but paint-bound (still repaint each frame). Excluded: SVG, onUpdate, transformTemplate (transform), handoff, layout-driven transforms.
  • Recommendation. Reframe the whole audit around this: optimization effort on buildHTMLStyles/renderHTML only pays off for JS-path elements. Prioritize widening WAAPI eligibility over making per-frame writes cheaper.

[MEDIUM] background-color/color deliberately excluded from acceleration

  • Mechanism. acceleratedValues 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.
  • Evidence. accelerated-values.ts:9-11; waapi.ts:60-62; is-browser-color.ts:1-14.
  • Expected impact + scaling. Per color-animating element per frame; common in hover/theme transitions. Scales with elements × frames (not × values).
  • Effort. Medium.
  • Risk. 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.
  • Naming bug to flag. The animation 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.

[MEDIUM] Box-shadow projection scale-correction re-parses the shadow string twice per frame

  • Mechanism. During layout animations, 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.
  • Evidence. 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).
  • Expected impact + scaling. Per box-shadow element per frame, only while a layout animation runs. Note: box-shadow is paint-bound, so the repaint is inherent — the double-parse is separable avoidable JS waste; the fix removes ~50% of the correction parse, not the whole cost. It is NOT the "dominant" per-frame cost (buildProjectionTransform + tree geometry math are comparable/larger).
  • Effort. Small — buildTransformer already accepts a ComplexValueInfo; call analyseComplexValue(latest) once and derive both.
  • Risk. Minimal; cache by source string for further savings.

[MEDIUM] Legacy full-rebuild on any single value change (no per-key dirty tracking)

  • Mechanism. Change handler is granular IN (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.
  • Evidence. 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).
  • Expected impact + scaling. JS-path only. O(keys in latestValues) per element per frame regardless of how many changed. Scales with 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.
  • Effort. Large.
  • Recommendation. Per-key dirty set marked in the change handler, OR port to the effects granular model (the principled fix).

[MEDIUM] buildTransform rebuilds the full transform string every frame

  • Mechanism. If any latestValues 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.
  • Evidence. 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.
  • Expected impact + scaling. Per transformed element per frame on the JS path; WAAPI-accelerated literal transforms skip it. One string alloc + one CSSOM write is the dominant sub-cost (loop body is cheap, defaults early-continue).
  • Effort. Medium.
  • Risk. The suggested 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.

[MEDIUM] Per-frame string allocations (transform, color, complex)

  • Mechanism. 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).
  • Evidence. 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.
  • Corrected impact. MEDIUM for transform; LOW for color/complex. The final string is irreducible for a changing value. Color/complex mixers are already well-optimized (object/array pooling) — list as "already good." The realistic win across all three is avoiding RE-conversion of UNCHANGED values via dirty tracking, not the conversion of the value that did change.
  • Effort. Small–Medium.

[MEDIUM] Projection willUpdate() does synchronous getBoundingClientRect in React commit

  • Mechanism. MeasureLayout.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.
  • Evidence. 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.
  • Expected impact + scaling. Per updating layout-animated component per layout-triggering commit. Inherent to FLIP (must measure "before" synchronously).
  • Effort. Medium.
  • Risk. Library code can itself interleave a write: 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).

[MEDIUM] Standalone (no-owner) MotionValues always animate on JS

  • Mechanism. 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.
  • Evidence. waapi.ts:39-49; value/index.ts:366-374. Correct browser constraint (no element to hand to element.animate).
  • Expected impact + scaling. Per standalone animated value × dependents in the chain × frames. This is the legitimate home of per-frame JS — so granular style-write optimizations matter MOST here.
  • Effort. Medium (mostly: accept it).
  • Notes. A WAAPI-accelerated owned value that is also a 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.

[MEDIUM] Effects is dead-internally — exported but consumed by no component

  • Mechanism. 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.
  • Evidence. 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.")
  • Expected impact + scaling. One-time; affects migration confidence/risk.
  • Effort. Medium.
  • Recommendation. Before default: (1) wire 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.

[MEDIUM] Springs/easings ARE compositor-accelerated via linear()

  • Mechanism (positive correction to a folk belief). 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().
  • Evidence. apply-generator.ts:9-10; spring.ts:448-455; map-easing.ts:13-16; default-transitions.ts:41-44.
  • Corrected impact: MEDIUM (was HIGH). Carve-outs: (a) browsers without 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.

Challenged Assumptions

Assumption testedVerdict
"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"FALSEdependents/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.

Myths Debunked (what NOT to waste time on)

  1. "Switch to cssText for fewer CSSOM ops." Slower, needs extra JS, and conflicts with projection's post-loop writes. Don't.
  2. "Route transform through a single CSS custom property." No write reduction; adds indirection. Don't.
  3. "camelCase vs setProperty is a perf choice worth refactoring." Sub-microsecond; dictated by correctness (vars need setProperty). Leave as-is.
  4. "Justify a rewrite by 'avoids N recalcs/frame.'" There is only ONE batched recalc per frame regardless. Justify by reduced JS string work + CSSOM setter calls instead.
  5. "Frameloop Set churn / 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.
  6. "Color/complex mixers churn objects/arrays per frame." blended object and mixArray output are reused; only the irreducible final string allocates. Already good.
  7. "Motion promotes every animated element to its own compositor layer." Auto will-change is dead-by-default; no translateZ(0). False worry.
  8. "Projection update thrashes layout per node." Textbook write→read→write batching; ≤1 recalc per flush. Correct as-is — add a regression guard against introducing getBoundingClientRect into notifyLayoutUpdate/resolveTargetDelta/calcProjection.

Prioritized Roadmap

Quick Wins (small effort, ship soon)

  • Box-shadow single-parse in correctBoxShadow — one-line change, removes ~50% of the per-frame correction parse during layout animations. [MEDIUM]
  • Fix the backwards SVG comment ("If this is an HTML element" → "If this is an SVG element", effects/style/index.ts:23). [LOW, correctness]
  • Drop the .trim() allocation in buildTransform (build-transform.ts:77) — append without trailing space. [LOW]
  • Thread currentTime from updateAndNotify into setCurrent (make the param optional for the constructor path). [LOW]
  • Add a regression guard/lint marker on projection write passes warning against getBoundingClientRect. [LOW]

Structural Bets (large effort, high leverage)

  • Widen WAAPI eligibility for transform shorthands (x/scale/rotate → one composited transform animation). The single biggest real-world per-frame JS reduction. [HIGH]
  • Re-enable color/background-color WAAPI (fix the backgroundColor naming, verify Chromium 41491098, frame as offload). [MEDIUM]
  • styleEffect as default for non-projected, no-feature elements behind a flag. Requires: (1) projection-inactive subtree gate; (2) feature parity — thread 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]
  • Reset will-change to auto via ref-counting active accelerated values + debounced removal. [LOW, latent bug]

Needs Measurement Before Acting

  • Benchmark styleEffect vs VisualElement on a many-element scene animating a NON-accelerated property (width, color, CSS var) and a many-tracked-static-keys scene. Measuring opacity/transform-string will show NO delta. Use Chrome (not jsdom/Electron — Blink's identical-write fast-path differs). [gates the whole structural bet]
  • Heap-allocation profile (DevTools Allocation Timeline) on a list of many JS-animated elements to size the string-alloc / dirty-tracking win.
  • Typed OM — only if Motion stops pre-stringifying; measure parse-cost delta + browser support first.
  • Multi-independent-key callback overhead in the effects render Set at high element counts (the one place granular could regress).
  • Scroll trackContentSize → ResizeObserver — non-trivial (must observe content, not container; page-scroll case has no stable content element).
  • Color WAAPI offload — confirm the win is the main-thread offload (testable), NOT free rendering (color repaints regardless).