When your dynamic rules stop installing, collide, or change meaning in Safari

At ZeroClick I owned the Safari and iOS extensions for Pie Adblock, our ad blocker with more than two million users. The extension had to change what it blocked while it was running. Users paused blocking on individual sites. A partnership feature let ads through while a participating creator’s video played. A set of rules downloaded from our server updated daily.
All of that ran on declarativeNetRequest, the browser API that blocks requests by matching them against rules you install, DNR from here on. Its static rulesets, the filter lists you compile at build time and ship in the package, cover none of this: the browser validates them before your extension ever runs, and their content is fixed. Runtime state means dynamic rules, the ones you add and remove with updateDynamicRules, and every feature above spent from the same shared pool of them.
That pool is where the trouble lived. Rules installed by old releases sat in it with nothing reclaiming them. One feature’s cleanup deleted another feature’s rules and switched blocking back on after a user had turned it off. The Safari build ran the same rule JSON with different meaning and broke sites that Chrome handled fine.
You may be facing the same shape of problem if your extension carries any blocking state it can’t know at build time: per-site pauses, user opt-ins, an allowlist your server controls. The moment a second feature starts writing rules, you inherit the accounting.
The problem sits at the base of modern extension engineering. Browsers now handle blocking declaratively: you hand the engine a rulebook ahead of time, it matches every request on its own, and your code never hears about individual requests. Teams hit the sharp edges when the rule set has to change at runtime, and hardest of all when they port to Safari, where the same rules can mean something else.
We tried the obvious approaches first. Cleanup that removed rules by whatever domain they matched caused the collision above. Trusting Chrome behavior on Safari broke real sites. What held up was encoding each rule’s owner and purpose into the rule ID itself, recomputing the rule set from state and installing only the difference, and converting every rule set for Safari before install. The sections below take the failures one at a time, symptom first, and show the pattern that replaced each.
Why dynamic rules suddenly stop installing
When updateDynamicRules starts failing months into production, the first thing to check is the shared budget. The dynamic-rule budget belongs to your extension as a whole, not to any one feature. Chrome allows 30,000 dynamic rules for “safe” actions since Chrome 121 and 5,000 for redirect and header-modifying rules. Safari’s budgets are smaller and vary by version.
A per-site pause, a partnership allowlist, and a user opt-in system all draw from the same pool, so any one of them can starve the rest. Staying under the budget takes two things: a known per-operation cost in rules for every runtime feature, and something that reclaims rules that have outlived their purpose.
The per-operation cost is easy to underestimate. On Pie Adblock, a single site pause cost two rules: an allowrule covering requests the site’s pages initiated, and an allowAllRequestsrule so the pages and frames themselves loaded untouched. Reclamation matters just as much. 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 can tell why.
Here’s the shape of that pause, so the two-rule cost is concrete:
// Pausing blocking on one site is two rules: one for the requests
// the site's pages make, one so the pages and frames themselves load.
const [allowId, framesId] = nextRuleIds('pausedByUser'); // next section
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [
{
id: allowId,
priority: 10000,
action: { type: 'allow' },
condition: { initiatorDomains: [domain] },
},
{
id: framesId,
priority: 10001,
action: { type: 'allowAllRequests' },
condition: {
initiatorDomains: [domain],
resourceTypes: ['main_frame', 'sub_frame'],
},
},
],
});You can’t tell which feature installed which rule
Six months from now, getDynamicRuleshands you back integers and match conditions, and nothing in the payload tells you which subsystem installed a rule or why. A dynamic rule is identified by an integer. There’s no label field, no tag, no metadata slot. The ID is the only field you have, which means the integer has to carry the answer: encode provenance in the ID itself.
Here’s the scheme that ran on Pie Adblock: an enum of two-digit prefixes. One meant “paused by the user.” Another meant “paused automatically by a partnership feature.” Another meant “ads allowed because the user opted in for one creator’s channel.” The digits after the prefix depended on the subsystem.
- Pause rules filled the remaining digits with six random digits.
- Rules converted from filter-list text used a hash of the rule text, so converting the same rule twice produced the same ID.
- Our hosted ruleset, the rules the extension downloads from a server at runtime, used plain unix timestamps as IDs. Unix timestamps currently start with 17, so the timestamp doubled as its own prefix and let us expire any rule older than the last merge into the static ruleset.
That scheme turned an opaque integer namespace into a queryable one. A question like “is this domain paused, and by whom?” became a matter of listing installed rules and decoding IDs, with no parallel bookkeeping store that could drift out of sync with what the browser had installed.
The whole scheme fits in a dozen lines. Pick any two-digit prefixes; the value is in the discipline, not the numbers:
// Two digits of "who installed this and why", then digits that
// vary by subsystem: random for pauses, a content hash for
// converted filter rules so reconverting yields the same ID.
const PREFIX = {
pausedByUser: '10',
pausedAutomatically: '11',
allowedForOptIn: '12',
};
function makeRuleId(reason) {
const suffix = String(Math.floor(Math.random() * 1e6)).padStart(6, '0');
return Number(PREFIX[reason] + suffix);
}
// The payoff: "is this domain paused, and by whom?" is answerable
// from the browser's own list. No side table to drift out of sync.
async function pausedByUser(domain) {
const rules = await chrome.declarativeNetRequest.getDynamicRules();
return rules.some(
(rule) =>
String(rule.id).startsWith(PREFIX.pausedByUser) &&
(rule.condition.initiatorDomains || []).includes(domain),
);
}When one feature’s cleanup removes another’s rules
Watch for this symptom: blocking that turns itself back on, or a user setting that unwinds on its own. The usual cause is two independent features that can each pause or allow the same site, with cleanup paths that collide.
We hit this class of bug on Pie Adblock with a partnership feature I built. It let ads through on YouTube while a participating creator’s video played, by pausing blocking on the domain and re-arming it on a ten-second timer. The teardown removed every allow rule matching the domain. So if the user had already paused blocking on YouTube themselves, the timer fired, swept the user’s pause rules out with the feature’s own, and flipped the blocker back on ten seconds after the user turned it off. The fix that shipped was a guard up front: before auto-pausing, the feature checked whether a pause the user owns was already in place, and if so, stood down.
The guard stopped the bleeding, but the durable rule is broader, and the ID scheme from the last section is what makes it cheap to follow: cleanup code lists dynamic rules, filters to its own ID namespace, and removes only those. Deleting by match condition is what lets one feature sweep away another’s rules. Each subsystem tears down what it installed and nothing else.
You need per-page behavior but rules only scope to domains
Say your extension needs different behavior on specific pages of a site: one channel, one section, one path. declarativeNetRequest can’t express that. allow and allowAllRequestsrules scope by URL pattern and initiator domain, the domain of the page that made the request, and there is no way to say “this rule applies to one section of a site.”
I hit the hardest version of this on Pie Adblock with a partnership feature I led that had to let ads through on specific creators’ channels on YouTube. YouTube gives a URL-based blocker nothing to grab. Ad media streams from the same googlevideo.com hosts that serve the video itself, and the ad instructions ride inside the same player response that carries the video data. Our blocking there worked by rewriting those responses in a content script, a script the extension injects into the page itself, stripping fields like adPlacements out of the JSON before the player read them. Block the URL and you block the video. So there was no ad URL to allow, either. 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 live in content scripts and extension state, and the blocking layer becomes a coarse on/off switch. Here’s how the shipped feature worked:
- A content script listened for YouTube’s page-load events and read the channel from the rendered page. The channel isn’t knowable until the page exists.
- If the user had opted in to that creator, the script messaged the background.
- The background paused our response rewriting for all of youtube.com, reloaded the tab, and re-armed everything on a five-second timer.
- A separate map in the background, keyed by tab ID, tracked which tab was in which mode, because the pause covered the whole domain.
Notice that no DNR rule changed hands. On YouTube there were none to change, and 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 keep the blocking layer as the on/off.
The same rules behave differently in Safari
When a rule set that works in Chrome breaks sites in Safari, or quietly does nothing there, you’re not misreading the documentation. The same rule JSON does not mean the same thing in Safari. On Pie Adblock we ran every rule set through a conversion pass before install. Here’s what it had to do:
- It renamed
initiatorDomainsto the olderdomainskey Safari still read. - It dropped every
allowAllRequestsrule, because Safari refused the domain-filtered kind we depended on. - It dropped redirect rules outright. Safari applied ours badly enough to break Spotify completely, and we never found the root cause.
- It split any rule carrying a
requestDomainsarray into one rule per domain, because a Safari bug maderequestDomainsanddomainsin the same rule take down the whole site. The first domain kept the original rule ID. Each additional clone got a deterministic hashed ID under its own prefix, so re-running the pass produced the same IDs every time.
A sketch of that pass, simplified but structurally accurate:
// Run on every rule set, right before install, Safari build only.
function toSafariRules(rules) {
return rules.flatMap((rule) => {
const condition = { ...rule.condition };
// Safari reads the older 'domains' key, not 'initiatorDomains'.
if (condition.initiatorDomains) {
condition.domains = condition.initiatorDomains;
delete condition.initiatorDomains;
}
// Safari mangles redirects and refuses the allowAllRequests
// rules we depend on. Dropping them beats shipping breakage.
if (rule.action.type === 'redirect') return [];
if (rule.action.type === 'allowAllRequests') return [];
// requestDomains + domains in one rule takes down the whole
// site in Safari, so each request domain becomes its own rule
// with a deterministic ID hashed from the rule's content.
if (condition.requestDomains) {
const domains = condition.requestDomains;
delete condition.requestDomains;
return domains.map((requestDomain, i) => ({
...rule,
id: i === 0 ? rule.id : hashRuleId(rule, requestDomain),
condition: { ...condition, urlFilter: '||' + requestDomain },
}));
}
return [{ ...rule, condition }];
});
}The allowAllRequests case deserves emphasis. A pause implemented as a two-rule pair on Chrome sheds half of itself on Safari, so a Safari pause was a semantically different operation from a Chrome pause built from the same source rules. The rule splitting cuts the other way and multiplies rule count, so your budget math changes per platform too. Verify behavior on each browser and version you support before you trust it.
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. That’s how Pie Adblock’s filter pipeline worked: convert the newest filter text into rules, list what the browser already had with getDynamicRules, then add and remove only the difference. Settings toggles did the same math against the list of enabled rulesets.
You can’t make rule decisions per request anyway. DNR is declarative by design: you hand the browser the rulebook ahead of time, the browser matches every request against it on its own, and your code never hears about individual requests. Chrome frames that as a privacy feature. The practical consequence is that your only lever is the installed rule set, so keep the code that produces it a straight function of state.
Watermark your IDs. The hosted-ruleset trick above generalizes: when IDs 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. 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?
Chrome allows 30,000 dynamic "safe" rules (block, allow, allowAllRequests, upgradeScheme) since Chrome 121, and 5,000 for rules that redirect or modify headers. Safari enforces its own, smaller budgets that differ by version. The practical consequence is the same everywhere: every subsystem that adds rules at runtime spends from one shared 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 you can watch matches live: load the extension unpacked (straight from a local folder, in developer mode), add the declarativeNetRequestFeedback permission, and the onRuleMatchedDebug event reports every rule match as it happens. Safari has no equivalent event. It does implement getMatchedRules, an after-the-fact query for recent matches, but bug reports against Safari’s version show it returning matches without the rule IDs, and the ID is the whole answer you’re after. So on Safari 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 only 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 can’t 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, refused domain-filtered allowAllRequests rules (so we dropped allowAllRequests entirely on Safari), mishandled redirect actions, and broke sites when requestDomains and domains appeared in the same rule, which forced us to split those into 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.
More guides
- Getting your Chrome extension onto Safari and iPhone: the complete guideWhat the converter does, what breaks, DNR, signing, and App Review.
- Safari has no blocking webRequest. Here's what to do instead.The declarativeNetRequest migration: what survives, what needs redesign.
- The converter ran fine. So why is your extension broken?Six silent failure modes of converted extensions and how to diagnose each.
Porting runtime rule management to Safari?
I ran this system in production on Pie Adblock, ZeroClick's two-million-user ad blocker, 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.