Skip to content

feat(record): add attributeFilter option for native MutationObserver filtering#1873

Open
kunalfermat wants to merge 2 commits into
rrweb-io:mainfrom
kunalfermat:feat/attribute-filter
Open

feat(record): add attributeFilter option for native MutationObserver filtering#1873
kunalfermat wants to merge 2 commits into
rrweb-io:mainfrom
kunalfermat:feat/attribute-filter

Conversation

@kunalfermat

Copy link
Copy Markdown

Summary

Add an attributeFilter option to record() that delegates attribute filtering to the browser's native MutationObserver.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+ style attribute 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:

  • ignoreCSSAttributes only filters CSSOM CSSStyleDeclaration.setProperty() calls, not inline style attribute changes observed by MutationObserver
  • hooks.mutation fires after collection — the expensive processing has already happened
  • blockSelector hides the element entirely from replay

Related issues: #197, #221, #668, #1447, #1820
Complementary to: #1694 (mutation emission throttling)

Solution

Pass attributeFilter through to the native MutationObserver.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.

record({
  emit(event) { /* ... */ },
  // Only observe class, value, and form-state attributes.
  // 'style' mutations (animations) never fire — zero CPU cost.
  attributeFilter: ['class', 'value', 'checked', 'selected', 'disabled', 'src', 'href'],
});

When attributeFilter is omitted or undefined, all attributes are observed (existing default behavior — fully backwards compatible).

Changes

  • packages/rrweb/src/types.ts — Added attributeFilter?: string[] to recordOptions, observerParam, and MutationBufferParam
  • packages/rrweb/src/record/observer.ts — Pass attributeFilter to observer.observe() when provided
  • packages/rrweb/src/record/index.ts — Wire attributeFilter from record options through to observer params
  • .changeset/add-attribute-filter.md — Minor changeset

Trade-offs

  • Opt-in only: No behavior change unless attributeFilter is explicitly set
  • All-or-nothing per attribute name: If you filter out style, ALL style mutations are invisible (not just animation ones). Users who need some style mutations recorded should not filter style
  • Replay fidelity: Filtered attributes won't appear in replay. This is acceptable for style animations (visually indistinguishable at 0/s vs 1,400/s) but users should be aware

Real-world impact

Tested on littlesleepies.com (product carousel + VIP badges):

  • Without filter: 988 mutation events in 15s idle, 15,763 attribute changes
  • With attributeFilter: ['class']: MutationObserver callback never fires for style changes → ~99% reduction in mutation processing

…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-bot

changeset-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 33080e5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 22 packages
Name Type
rrweb Major
@rrweb/rrweb-plugin-canvas-webrtc-record Major
@rrweb/rrweb-plugin-canvas-webrtc-replay Major
@rrweb/rrweb-plugin-console-record Major
@rrweb/rrweb-plugin-console-replay Major
@rrweb/rrweb-plugin-network-record Major
@rrweb/rrweb-plugin-network-replay Major
@rrweb/rrweb-plugin-sequential-id-record Major
@rrweb/rrweb-plugin-sequential-id-replay Major
rrweb-snapshot Major
rrdom Major
rrdom-nodejs Major
rrweb-player Major
@rrweb/all Major
@rrweb/replay Major
@rrweb/record Major
@rrweb/types Major
@rrweb/packer Major
@rrweb/utils Major
@rrweb/browser-client Major
@rrweb/web-extension Major
rrvideo Major

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

@eoghanmurray

eoghanmurray commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

This looks like an interesting idea and cuts off the problem at the source, preventing unnecessary processing in rrweb.
Critique; this is putting a big burden on the user of rrweb to correctly enumerate all attributes that may an effect on the playback.

An alternate take that I haven't had a chance to revisit recently is #1694 (throttling mutation emission)
If you hadn't already seen that, it would be great to see if it alleviates the problem for your use case.

Edit: I see you mentioned #1694 already — did you test out any throttling values for your problem website?

@eoghanmurray

Copy link
Copy Markdown
Contributor

There is also (undocumented I now realize) record.freezePage option which stops emission of mutations (although doesn't stop the processing which appears to be a factor in your case) ... I'd usually have freezePage kick in after a certain period of user inactivity.

@kunalfermat

Copy link
Copy Markdown
Author

This looks like an interesting idea and cuts off the problem at the source, preventing unnecessary processing in rrweb. Critique; this is putting a big burden on the user of rrweb to correctly enumerate all attributes that may an effect on the playback.

An alternate take that I haven't had a chance to revisit recently is #1694 (throttling mutation emission) If you hadn't already seen that, it would be great to see if it alleviates the problem for your use case.

Edit: I see you mentioned #1694 already — did you test out any throttling values for your problem website?

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.

@kunalfermat

Copy link
Copy Markdown
Author

There is also (undocumented I now realize) record.freezePage option which stops emission of mutations (although doesn't stop the processing which appears to be a factor in your case) ... I'd usually have freezePage kick in after a certain period of user inactivity.

record.freezePage — interesting, didn't know about that. For our use case (session recording on e-commerce sites), the animations fire continuously even while users are actively browsing/adding to cart, so freezing on inactivity wouldn't catch the overlap period. But it's a good tool for other scenarios.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants