Dynamic declarativeNetRequest rules: scoping, budgeting, and safely undoing them

Static declarativeNetRequest rulesets are the easy half of the API. You compile a filter list at build time, ship it in the package, and the browser validates it before your extension ever runs. The hard half is dynamic rules — the ones you add and remove at runtime in response to user state. A user pauses blocking on one site. A feature temporarily allows requests somewhere. Server config changes an allowlist. Each of those is an updateDynamicRules call, and the API gives you almost no structure for managing what accumulates. At Pie, a 2M+ user ad blocker where I owned the Safari and iOS extension domain, dynamic rules were where every hard DNR problem lived. Here is what held up.

Every feature spends from one budget

The dynamic-rule budget — on the order of 5,000 rules in the versions we shipped against — belongs to your extension as a whole, not to any one feature. A per-site pause, a partnership allowlist, and a user opt-in system all draw from the same pool. At Pie, a single site pause cost two rules, so the accounting mattered: every runtime feature needed a known per-operation cost in rules, and something had to reclaim rules that outlived their purpose. If you don’t garbage-collect, rules installed by a previous release sit in the pool forever, invisible, until a new feature starts failing to install rules and nobody knows why.

Rule IDs are the only metadata you get

A dynamic rule is identified by an integer. There is no label field, no tag, no metadata slot. Six months from now, getDynamicRules hands you back integers and match conditions, and nothing in the payload tells you which subsystem installed a rule or why. The fix is to make the integer carry the answer: encode provenance in the ID itself.

At Pie we kept an enum of leading digits. One prefix meant “paused by the user.” Another meant “paused automatically by a partnership feature.” Another meant “allowed by a per-channel user opt-in.” The remaining digits held a timestamp or a hash. That scheme turned an opaque integer namespace into a queryable one. A predicate like “is this domain paused, and by whom?” became a matter of listing installed rules and decoding IDs — no parallel bookkeeping store that could drift out of sync with what the browser actually had installed.

Teardown must be provenance-aware

The ID scheme earns its keep at removal time. Several independent features can each pause or allow the same site. A user pauses blocking on a domain; later, an automatic feature pauses the same domain for its own reasons. If that feature’s cleanup step removes “the rules for this domain,” it tears down the user’s pause along with its own — and silently re-enables blocking the user explicitly turned off. We hit exactly this class of bug at Pie: a partnership feature that auto-paused blocking on specific sites had to check for an existing user pause first, because its automatic re-arm would otherwise have flipped the blocker back on about ten seconds after the user turned it off.

The rule that came out of it: removal code never deletes by match condition. It lists dynamic rules, filters to its own ID namespace, and removes only those. Each subsystem tears down what it installed and nothing else.

A domain is the finest scope you get

allow and allowAllRequestsrules scope by URL pattern and initiator domain. That sounds flexible until you need something finer than a domain — and you cannot express “this rule applies to one section of a site.” At Pie I led a partnership feature that had to let ads through on specific channels of a video platform. On YouTube, ad media is served from the same hosts as the video content itself, and the blocking that matters happens at the response level, not the URL level. There was no URL to allow. The only thing DNR could express was all of youtube.com or nothing.

The design consequence generalizes: any decision finer than a domain has to move out of DNR and into content scripts and extension state. DNR becomes a coarse switch. In the shipped design, a content script identified the channel after the page loaded, the background flipped a domain-wide pause for that tab for a few seconds, and separate state tracked which tab was in which mode. The rules themselves never knew channels existed. If you find yourself trying to encode application logic into match patterns, stop — put the logic where it can see the page, and let DNR handle the on/off.

Safari changes what your rules mean

The same rule JSON does not mean the same thing in Safari. Our conversion layer at Pie handled, among other things: initiatorDomains translated to the older domains key; allowAllRequestsrules with domain filters dropped entirely, because Safari didn’t support them; redirect actions dropped; and rules combining requestDomains arrays with domain scoping split into several single-domain rules, each with a synthetic hashed ID under its own namespace prefix.

The allowAllRequests case deserves emphasis. A pause implemented as a two-rule pair on Chrome sheds half of itself on Safari. The code comment in our converter said we hoped the remaining allow rule covered the same ground. That hope was load-bearing. A Safari pause was a semantically different operation from a Chrome pause built from the same source rules. The rule splitting also multiplies rule count, so your budget math changes per platform too. Verify behavior on each browser and version you support; parity is a claim to test, not a property of the API.

Patterns that held up in production

Recompute, don’t react.Build a rule compiler: a function from current state — user settings, server config, active pauses — to the rule set that should exist. When state changes, recompute and diff against what’s installed. You cannot make rule decisions per request anyway; DNR doesn’t run your code in the request path.

Watermark your IDs. Because IDs can carry timestamps, they double as garbage-collection watermarks. On startup, compare installed rule IDs against the last ruleset update and drop anything stale. This is what keeps rules from a release two versions ago from leaking budget indefinitely.

Keep automatic pauses out of the UI.When a feature pauses blocking on the user’s behalf, don’t flip the “your protection is paused” indicator. At Pie the auto-pause set no user-facing flag, because showing one would have told users their protection was off when, from their point of view, it wasn’t. Provenance-encoded IDs make this distinction cheap to maintain.

Test per browser version. Every divergence in the Safari section above was discovered by running the real rule set on real Safari, not by reading documentation. Budgets, supported keys, and action types all vary by version.

Each of these patterns exists because a bug forced it. The API surface is small — updateDynamicRules, getDynamicRules, and an integer namespace — and the structure it doesn’t provide, you have to build: an ID scheme, a budget ledger, and a teardown discipline that respects both.

Questions clients ask

How many dynamic declarativeNetRequest rules can an extension have?

The dynamic-rule budget has been on the order of 5,000 rules, and it is shared across your entire extension — not allocated per feature. Exact limits vary by browser and version, so verify against the ones you ship to. The practical consequence is the same everywhere: every subsystem that adds rules at runtime spends from one pool, so you need to know what each operation costs in rules and garbage-collect stale ones.

How do I debug which rule matched a request?

In Chrome, an unpacked extension with the declarativeNetRequestFeedback permission can listen to onRuleMatchedDebug and see every match. Safari has no equivalent, so you fall back to reading your installed rules with getDynamicRules, decoding your own rule-ID scheme to see which subsystem installed what, and testing behavior against real sites. This is one of the reasons encoding provenance in rule IDs pays off — without it, a dump of installed rules is just integers and match patterns.

When should I use static rulesets instead of dynamic rules?

Use static rulesets for anything you know at build time: filter lists, baseline blocking, rules that only need to be toggled on or off as a set. They don’t spend your dynamic budget and they’re validated at package time. Reserve dynamic rules for state you genuinely cannot know until runtime — per-site pauses, user opt-ins, server-driven allowlists. If a rule’s content never changes and only its enabled state does, it belongs in a static ruleset.

Do dynamic DNR rules behave the same in Safari as in Chrome?

No. In the versions I shipped against, Safari used the older domains key where Chrome used initiatorDomains, dropped allowAllRequests rules that carried domain filters, dropped redirect actions, and required rules combining requestDomains with domain scoping to be split into several single-domain rules. The same rule set can mean something materially different per platform, so treat parity as a claim to verify per browser version, not an assumption.

Porting runtime rule management to Safari?

I ran this system in production at Pie, an ad blocker with two million users whose pause, allowlist, and partnership features all drew from one dynamic-rule pool across Chrome and Safari. The Safari port assessment is $2,500, credited toward follow-on work: I inventory your dynamic-rule usage, flag every rule Safari will drop, rewrite, or split, and hand you a fixed quote for the port.

Book a free intro callEmail me insteadI personally reply within one business day. · Currently booking September 2026.