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
- 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.
- 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.
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.
- 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²).
- Memoize
selectorsFor(url). Cache by URL; invalidate on route change. Removes per-scan URLPattern.test from the hot path.
- Pause on
visibilitychange. Disconnect observers when tab hidden; one settle scan on visible. Free win for background tabs.
Tier 1S — SPA route transitions specifically
- 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.
- 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.
- 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
- 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.
- 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.
- 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.
- 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.
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
- 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.
- 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.
- 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
content-visibility: auto on block placeholders. Below-the-fold placeholders skip layout/paint.
IntersectionObserver for placeholder upgrade. Hide synchronously via CSS marker; build the placeholder <button>/label/icon only as elements approach the viewport.
requestIdleCallback for annotation rules. Annotation rules don't affect first-paint correctness — defer during scroll bursts. Hide/redact rules stay on the rAF path.
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.
- 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
- 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.
- PR 2 (Tier 1S, scoped): route-change signal + transition pause-and-sweep, detached-subtree fast-path. Decouples SPA pain from scroll pain.
- PR 3 (architecture): shared mutation router + id/class token index. Unlocks scoped-subtree scanning.
- PR 4 (CSS-first): lift static
alwaysOnSelectors into injected stylesheets; JS path only for placeholder UI / dynamic selectors. Big steady-state win.
- 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
Problem
Our content-script rule engine slows down on two workloads:
Current pipeline cost
MutationObserverondocument.bodyvialib/subtree-watcher.ts— no shared mutation router.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).document.body.querySelectorAll(allSelectors.join(','))— full-document, never scoped to the inserted subtree. The comment inlib/selector-hide-rule.ts:158explains why: top-level container insertions wouldn't match QSA on themselves.lib/selector-hide-rule.ts:127is O(C²) in matched candidates (candidates.some(other => other.contains(element))).attributeFilter, no burst-size awareness, no visibility pause, no SPA-route-change signal.selectorsFor(url)rebuilds the selector list per scan, re-running everyURLPattern.test.How major OSS blockers handle this
documentSafeAnimationFrame,<100 nodes ? 1ms : rAFdocument,attributeFilter:['id','class']setTimeout(150ms)(notes rAF "stalls on battery saver")documentElementrequestIdleCallback, ~60 selectors/pumpclass→selectors/id→selectorsreverse index; renderer tracksqueriedClassesSetsetIntervalpolling when mutation rate exceeds threshold; reattach after 10s of calmattributeFilter:['class','id','href']debounce(25ms, maxWait: 1000ms)+ immediate flush at >512 pending nodesCommon 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
lib/subtree-watcher.ts:84runs both leading and trailing — every burst scans twice. Switch to trailing-only or Ghostery-style debounce. Halves scan count per burst.ignoreTagsfilter at enqueue. Dropscript, style, link, meta, head, bratlib/subtree-watcher.ts:57before buffering. Cheaper than letting them flow through.lib/selector-hide-rule.ts:128: put candidates in aSet, walkparentElementupward, keep only those whose first ancestor in the Set is themselves. O(C·D) instead of O(C²).selectorsFor(url). Cache by URL; invalidate on route change. Removes per-scanURLPattern.testfrom the hot path.visibilitychange. Disconnect observers when tab hidden; one settle scan on visible. Free win for background tabs.Tier 1S — SPA route transitions specifically
window.navigation?.addEventListener('navigatesuccess', …)withpushState/replaceState/popstatefallback. On a detected route change: cancel in-flight throttled drains, callobserver.takeRecords(), do one full sweep. Avoids the cascade where a 5k-node burst splits across 20 throttle windows.drain()filtersisConnectedbutenqueue()does not. A 5k-burst with 30% churn still enqueues all 5k. FilterisConnectedat enqueue + drain.Tier 2 — bigger architecture shifts
document, fanning out to subscribers (uBO'ssafeObserverHandlermodel). Collapses N callback invocations into one per mutation event; one place forignoreTagsfiltering.```
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.document.body. Once (11) lands, the comment inlib/selector-hide-rule.ts:158stops being a blocker — we no longer rely on QSA-from-body to find new matches.<style>with[selectors] { display:none !important }at apply time for staticalwaysOnSelectorsandremoveEntirelyrules. 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.WeakSet<Element>per rule of processed nodes. Today we do 5getAttributereads per candidate againstHIDDEN_ATTR/REVEALED_ATTRmarkers (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
observer.disconnect()and switch tosetInterval(scan, 500ms)for 10s, then reattach. Counterintuitive but real on hot feeds: observer callbacks themselves are the cost.lastScanGenerationand skips if unchanged. Catches the redundant trailing scan even if we keep leading+trailing.Tier 3 — longer-shot / speculative
content-visibility: autoon block placeholders. Below-the-fold placeholders skip layout/paint.IntersectionObserverfor placeholder upgrade. Hide synchronously via CSS marker; build the placeholder<button>/label/icon only as elements approach the viewport.requestIdleCallbackfor annotation rules. Annotation rules don't affect first-paint correctness — defer during scroll bursts. Hide/redact rules stay on the rAF path.AbortSignal-cancellable scans for text-heavy rules.pii-redact,secrets-redact,encoded-payload-redact,prompt-injection-redactwalk largeinnerText. A scan started against the old tree should bail when the new tree arrives.easylist-generic.generated.ts. That generated file is huge. Ship those selectors as a stylesheet injected atdocument_start(uBO's EasyList approach). Should be near-zero-cost since those selectors don't need placeholders.Recommended sequencing
ignoreTagsat enqueue, fix O(C²) outermost-match,selectorsFormemoization, visibility pause. Pure plumbing wins, no architectural change.alwaysOnSelectorsinto injected stylesheets; JS path only for placeholder UI / dynamic selectors. Big steady-state win.Before PR 1, worth landing a measurement probe: tap
performance.mark/measurearound each rule'sscan()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
src/js/contentscript.js,src/js/contentscript-extra.js,src/js/cosmetic-filtering.jsthrottle-wrapper.ts,document-observer.ts,mutation-observer.tscontent_cosmetic.ts,cosmetic_filter_cache.rsadblocker-content/src/index.ts,reverse-index.ts