feat(record): add attributeFilter option for native MutationObserver filtering#1873
feat(record): add attributeFilter option for native MutationObserver filtering#1873kunalfermat wants to merge 2 commits into
attributeFilter option for native MutationObserver filtering#1873Conversation
…r filtering
CSS animations on carousels, sliders, and animated badges can generate
1,000+ style attribute mutations per second with zero user interaction.
Today there is no way to prevent these from hitting the MutationObserver
callback — `ignoreCSSAttributes` only filters CSSOM `setProperty` calls,
not inline `style` attribute changes observed by MutationObserver.
This adds an `attributeFilter` option to `record()` that passes through
to `MutationObserver.observe()`. When set, the browser's native
filtering prevents unlisted attributes from ever firing the JS callback,
eliminating CPU overhead entirely — not just filtering after the fact.
Usage:
```js
record({
emit(event) { ... },
attributeFilter: ['class', 'value', 'checked', 'selected', 'disabled'],
// 'style' mutations never fire — zero overhead
});
```
When omitted, all attributes are observed (existing default behavior).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 33080e5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 22 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
This looks like an interesting idea and cuts off the problem at the source, preventing unnecessary processing in rrweb.
Edit: I see you mentioned #1694 already — did you test out any throttling values for your problem website? |
|
There is also (undocumented I now realize) |
Thanks for the feedback @eoghanmurray ! On the ergonomics concern — totally fair. An allowlist puts the burden on users to enumerate every attribute they care about, which is error-prone. A simpler alternative would be an inverse option like ignoreAttributes: ['style'] — users only list what to exclude, which is a much shorter list (usually just style). That's easier to reason about and harder to get wrong. Happy to rework the API in that direction if you think it's a better fit. On #1694 (mutation emission throttling) — we actually implemented something very similar on our side. Our pixel has an emit-callback throttle that drops style-only attribute mutations firing faster than 100ms apart. It works well for bandwidth/storage (~85% reduction in mutation events on our noisiest brand site), but doesn't help with CPU because rrweb's MutationObserver callback still fires 1,400+/s and processes every mutation before our throttle sees it. That's what motivated the native attributeFilter approach — the browser never fires the JS callback for filtered-out attributes, so the expensive processMutation path (getAttribute, style diffing, etc.) never runs. That said, #1694 and this PR are complementary — throttling controls how often mutations are emitted, while attributeFilter/ignoreAttributes controls which mutations are observed. Both have value for different use cases. |
|
Summary
Add an
attributeFilteroption torecord()that delegates attribute filtering to the browser's nativeMutationObserver.observe({ attributeFilter }). When set, unlisted attributes never fire the JS callback, eliminating CPU overhead from high-frequency style animations entirely — not just filtering after the fact.Problem
CSS animations on carousels, sliders, and animated badges generate 1,000+
styleattribute mutations per second with zero user interaction. On sites like littlesleepies.com, 99% of all rrweb events are attribute mutations from a product carousel (7,200 mutations/5s) and animated VIP badges (1,200 mutations/5s).Today there is no way to prevent these from reaching the MutationObserver callback:
ignoreCSSAttributesonly filters CSSOMCSSStyleDeclaration.setProperty()calls, not inlinestyleattribute changes observed by MutationObserverhooks.mutationfires after collection — the expensive processing has already happenedblockSelectorhides the element entirely from replayRelated issues: #197, #221, #668, #1447, #1820
Complementary to: #1694 (mutation emission throttling)
Solution
Pass
attributeFilterthrough to the nativeMutationObserver.observe()call. This is the most performant approach — the browser never fires the JS callback for filtered-out attributes, so there is zero CPU overhead per-mutation for unlisted attributes.When
attributeFilteris omitted or undefined, all attributes are observed (existing default behavior — fully backwards compatible).Changes
packages/rrweb/src/types.ts— AddedattributeFilter?: string[]torecordOptions,observerParam, andMutationBufferParampackages/rrweb/src/record/observer.ts— PassattributeFiltertoobserver.observe()when providedpackages/rrweb/src/record/index.ts— WireattributeFilterfrom record options through to observer params.changeset/add-attribute-filter.md— Minor changesetTrade-offs
attributeFilteris explicitly setstyle, ALL style mutations are invisible (not just animation ones). Users who need some style mutations recorded should not filterstylestyleanimations (visually indistinguishable at 0/s vs 1,400/s) but users should be awareReal-world impact
Tested on littlesleepies.com (product carousel + VIP badges):
attributeFilter: ['class']: MutationObserver callback never fires for style changes → ~99% reduction in mutation processing