Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Perf: speed up rule application during fast infinite scroll and SPA route transitions #150

Description

@twschiller

Problem

Our content-script rule engine slows down on two workloads:

  • Fast infinite scroll (Twitter/Reddit/Mastodon-style feeds) — sustained bursts of small mutations.
  • SPA route transitions (React/Vue/Svelte route swaps) — a single giant burst, often 5k+ DOM ops inside one microtask.

Current pipeline cost

  • ~24 rules independently construct their own MutationObserver on document.body via lib/subtree-watcher.ts — no shared mutation router.
  • Each observer uses throttle(drain, 250, { leading: true, trailing: true }), so a burst causes 2 scans per rule (~48 scans per burst window with the full rule set on).
  • Every scan is document.body.querySelectorAll(allSelectors.join(',')) — full-document, never scoped to the inserted subtree. The comment in lib/selector-hide-rule.ts:158 explains why: top-level container insertions wouldn't match QSA on themselves.
  • Outermost-match filter at lib/selector-hide-rule.ts:127 is O(C²) in matched candidates (candidates.some(other => other.contains(element))).
  • No attributeFilter, no burst-size awareness, no visibility pause, no SPA-route-change signal.
  • selectorsFor(url) rebuilds the selector list per scan, re-running every URLPattern.test.

How major OSS blockers handle this

Observer scope Throttle Selector dispatch Burst escape
uBlock Origin 1× on document SafeAnimationFrame, <100 nodes ? 1ms : rAF Hash buckets keyed by id/class tokens; only matching rules run 200ms per-procedural-selector budget refilled every 2s
AdGuard ExtendedCss 1× on document, attributeFilter:['id','class'] setTimeout(150ms) (notes rAF "stalls on battery saver") None — relies on filtering + debounce Circuit breaker for runaway re-apply loops
Brave Shields 1× on documentElement requestIdleCallback, ~60 selectors/pump Rust-side class→selectors / id→selectors reverse index; renderer tracks queriedClasses Set Disconnect observer, fall back to setInterval polling when mutation rate exceeds threshold; reattach after 10s of calm
Ghostery adblocker 1×, attributeFilter:['class','id','href'] debounce(25ms, maxWait: 1000ms) + immediate flush at >512 pending nodes Reverse index keyed by each filter's rarest token Hard cap drains the queue

Common shape: one observer, indexed dispatch by id/class token, CSS does the hiding, JS only does dispatch and dynamic decoration.

Ideas, ranked by impact × effort

Tier 1 — quick wins, contained changes

  1. Trailing-only throttle (or debounce + maxWait). lib/subtree-watcher.ts:84 runs both leading and trailing — every burst scans twice. Switch to trailing-only or Ghostery-style debounce. Halves scan count per burst.
  2. Burst-size flush. Adopt Ghostery's `>512 pending nodes → drain immediately` and uBO's `<100 ? 1ms : next rAF`. Avoids the worst case where one 250ms window accumulates thousands of nodes before draining.
  3. ignoreTags filter at enqueue. Drop script, style, link, meta, head, br at lib/subtree-watcher.ts:57 before buffering. Cheaper than letting them flow through.
  4. Fix O(C²) outermost-match. lib/selector-hide-rule.ts:128: put candidates in a Set, walk parentElement upward, keep only those whose first ancestor in the Set is themselves. O(C·D) instead of O(C²).
  5. Memoize selectorsFor(url). Cache by URL; invalidate on route change. Removes per-scan URLPattern.test from the hot path.
  6. Pause on visibilitychange. Disconnect observers when tab hidden; one settle scan on visible. Free win for background tabs.

Tier 1S — SPA route transitions specifically

  1. Route-change signal as proactive coalesce. Hook window.navigation?.addEventListener('navigatesuccess', …) with pushState/replaceState/popstate fallback. On a detected route change: cancel in-flight throttled drains, call observer.takeRecords(), do one full sweep. Avoids the cascade where a 5k-node burst splits across 20 throttle windows.
  2. Pause-and-batch around route transitions. Same hook, opposite direction: disconnect for the transition's duration (~one rAF), sweep, reattach. Eliminates feedback where our own placeholder insertions feed back into the observer mid-transition. uBO doesn't need this because CSS does the hiding — our placeholder injection does write to the DOM.
  3. Detached-subtree fast-path. drain() filters isConnected but enqueue() does not. A 5k-burst with 30% churn still enqueues all 5k. Filter isConnected at enqueue + drain.

Tier 2 — bigger architecture shifts

  1. Single shared mutation router. Replace 24 observers with one on document, fanning out to subscribers (uBO's safeObserverHandler model). Collapses N callback invocations into one per mutation event; one place for ignoreTags filtering.
  2. Id/class token index across all rules. At module load, compile each selector list into:
    ```
    idIndex: Map<id, ruleIds[]>
    classIndex: Map<class, ruleIds[]>
    complexFallback: ruleIds[] // attribute/combinator/pseudo selectors
    ```
    On each added node, read node.id + node.classList — dispatch only to indexed rules + the complex fallback. Turns "full-doc QSA per rule" into "constant-time lookup per added node." uBO/Brave/Ghostery all converge on this independently.
  3. Scan from inserted roots, not from document.body. Once (11) lands, the comment in lib/selector-hide-rule.ts:158 stops being a blocker — we no longer rely on QSA-from-body to find new matches.
  4. CSS-first hiding for static selectors. Inject a per-rule <style> with [selectors] { display:none !important } at apply time for static alwaysOnSelectors and removeEntirely rules. CSSOM does the matching for free, forever. JS path only runs for placeholder UI / dynamic selectors. Cuts steady-state mutation work to near-zero for ~half the rules.
  5. WeakSet<Element> per rule of processed nodes. Today we do 5 getAttribute reads per candidate against HIDDEN_ATTR / REVEALED_ATTR markers (lib/selector-hide-rule.ts:116-124). Markers stay for cross-rule coordination; the WeakSet is a perf bypass for the hot loop.

Tier 2B — adaptive escape hatches

  1. Brave-style observer-to-polling switch. When sustained mutation rate exceeds threshold (e.g., >2k added nodes/sec for 1s), observer.disconnect() and switch to setInterval(scan, 500ms) for 10s, then reattach. Counterintuitive but real on hot feeds: observer callbacks themselves are the cost.
  2. Per-rule ms budget + auto-disable. uBO's 200ms/2s refill. Rules that exceed their budget on a page get disabled with a warning. Stops pathological sites from making the whole extension feel broken.
  3. DOM-generation counter, scan-only-if-changed. Shared router increments a counter per batch; each rule keeps lastScanGeneration and skips if unchanged. Catches the redundant trailing scan even if we keep leading+trailing.

Tier 3 — longer-shot / speculative

  1. content-visibility: auto on block placeholders. Below-the-fold placeholders skip layout/paint.
  2. IntersectionObserver for placeholder upgrade. Hide synchronously via CSS marker; build the placeholder <button>/label/icon only as elements approach the viewport.
  3. requestIdleCallback for annotation rules. Annotation rules don't affect first-paint correctness — defer during scroll bursts. Hide/redact rules stay on the rAF path.
  4. AbortSignal-cancellable scans for text-heavy rules. pii-redact, secrets-redact, encoded-payload-redact, prompt-injection-redact walk large innerText. A scan started against the old tree should bail when the new tree arrives.
  5. CSS-first for easylist-generic.generated.ts. That generated file is huge. Ship those selectors as a stylesheet injected at document_start (uBO's EasyList approach). Should be near-zero-cost since those selectors don't need placeholders.

Recommended sequencing

  1. PR 1 (Tier 1, ~no risk): trailing-only throttle, burst-size flush, ignoreTags at enqueue, fix O(C²) outermost-match, selectorsFor memoization, visibility pause. Pure plumbing wins, no architectural change.
  2. PR 2 (Tier 1S, scoped): route-change signal + transition pause-and-sweep, detached-subtree fast-path. Decouples SPA pain from scroll pain.
  3. PR 3 (architecture): shared mutation router + id/class token index. Unlocks scoped-subtree scanning.
  4. PR 4 (CSS-first): lift static alwaysOnSelectors into injected stylesheets; JS path only for placeholder UI / dynamic selectors. Big steady-state win.
  5. PR 5 (escape hatch): Brave-style observer-to-poll switch under sustained burst + per-rule budget.

Before PR 1, worth landing a measurement probe: tap performance.mark/measure around each rule's scan() and around the router drain, log p50/p95 per rule. Then run a scripted Twitter/Reddit scroll and a SPA route hop to get a baseline.

References — source files to mine for techniques

Metadata

Metadata

Assignees

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions