Staying Under the Rule Count Limits
Budget declarativeNetRequest rules across static, dynamic and session stores — the guaranteed minimums, what regex rules cost extra, and how to compress a large filter list.
Table of Contents
A filter list of forty thousand entries does not fit, and the browser tells you so in the least helpful way available: updateDynamicRules rejects with a quota message, or a static ruleset simply does not load and the extension behaves as if you never wrote it. The limits are not one number — there are separate budgets for static, dynamic and session rules, and a separate, much smaller one for rules that use a regular expression. This guide is part of declarativeNetRequest rules.
The budgets
A rule counts once wherever it lives. A rule with a regexFilter counts against both its own store and the shared regex allowance. Disabled static rulesets do not count against the enabled budget, which is what makes the toggle in dynamic vs static rulesets so useful.
Step-by-step: fitting a large list
1. Measure before optimising
1const [dyn, sess, enabled] = await Promise.all([
2 chrome.declarativeNetRequest.getDynamicRules(),
3 chrome.declarativeNetRequest.getSessionRules(),
4 chrome.declarativeNetRequest.getEnabledRulesets(),
5]);
6
7const regexCount = [...dyn, ...sess].filter((r) => r.condition.regexFilter).length;
8console.table({ dynamic: dyn.length, session: sess.length, regexAtRuntime: regexCount, enabled });
Execution context: the service worker console. This does not count regex rules inside static files — count those at build time, because by the time the browser rejects the ruleset there is no API that will tell you which rule was over budget.
2. Replace regex with urlFilter wherever possible
urlFilter has its own small pattern language — || for a domain anchor, ^ for a separator, * for a wildcard — and it does not touch the regex budget. Most filter-list entries translate directly.
1// Before: burns one of your 1,000 regex slots
2{ condition: { regexFilter: "^https://ads\\.example\\.(com|net)/" } }
3
4// After: two urlFilter rules, zero regex slots
5{ condition: { urlFilter: "||ads.example.com^" } }
6{ condition: { urlFilter: "||ads.example.net^" } }
Execution context: build-time rule generation. Two cheap rules beat one expensive one here, because the budget that runs out first is the regex one.
3. Collapse rules with requestDomains
A single rule can carry a list of domains, which is the largest single saving available on a typical blocklist.
1// 400 rules become 1
2{
3 "id": 5,
4 "priority": 1,
5 "action": { "type": "block" },
6 "condition": {
7 "requestDomains": ["ads.example.com", "trk.example.net", "px.example.org" /* … */],
8 "resourceTypes": ["script", "xmlhttprequest", "image"]
9 }
10}
Execution context: the network stack. requestDomains matches the domain and all its subdomains, so listing example.com covers a.b.example.com — check your source list is not already expanding subdomains separately.
4. Split by what the user actually enabled
Ship the long tail as additional rulesets that are disabled by default and enable them only when the user opts in. Disabled rulesets cost nothing against the enabled budget.
1async function applyLists(selected) {
2 const all = ["core", "annoyances", "social", "regional-de", "regional-fr"];
3 await chrome.declarativeNetRequest.updateEnabledRulesets({
4 enableRulesetIds: selected,
5 disableRulesetIds: all.filter((id) => !selected.includes(id)),
6 });
7}
Execution context: the service worker, usually called from an options-page message. There is also a cap on the number of enabled rulesets at once — keep the list to a handful of large files rather than dozens of small ones.
5. Fail loudly at build time
The one thing worse than hitting the limit is hitting it silently in a release. Count rules in the build.
1// build/check-rules.mjs
2import { readFileSync, readdirSync } from "node:fs";
3
4const LIMITS = { static: 30000, regex: 1000 };
5let total = 0, regex = 0;
6
7for (const f of readdirSync("src/rules")) {
8 const rules = JSON.parse(readFileSync(`src/rules/${f}`, "utf8"));
9 total += rules.length;
10 regex += rules.filter((r) => r.condition?.regexFilter).length;
11}
12
13if (total > LIMITS.static) throw new Error(`static rules ${total} > ${LIMITS.static}`);
14if (regex > LIMITS.regex) throw new Error(`regex rules ${regex} > ${LIMITS.regex}`);
15console.log(`rules ok — ${total} total, ${regex} regex`);
Execution context: Node, during the build. Wire it into the same pipeline that packages the extension, as described in building a GitHub Actions pipeline for extensions.
Where the rules come from, and keeping the pipeline honest
Most extensions that run into these limits are consuming a list somebody else maintains — an ad-block filter list, a tracker registry, a corporate blocklist — and regenerating rules from it on a schedule. That pipeline is where the budget is either respected or quietly blown, usually months after anyone looked at it.
Three properties make a generator safe to leave running.
It is deterministic. The same input list must produce the same rule ids, or every regeneration invalidates every id and makes diffs unreadable. Derive ids from a hash of the pattern rather than from array position:
1import { createHash } from "node:crypto";
2
3const idFor = (pattern) =>
4 1 + (parseInt(createHash("sha1").update(pattern).digest("hex").slice(0, 8), 16) % 99_999);
Execution context: Node, during rule generation. Collisions are possible in a space of 100,000 and must be resolved explicitly — detect them and probe the next free id rather than letting one rule silently overwrite another.
It reports its own numbers. Every regeneration should print the counts it produced and the headroom remaining, so a list that grew 20% is visible in a build log rather than in a bug report.
It degrades predictably. When the source list exceeds the budget, dropping the tail is better than failing the build only if the ordering is meaningful. Filter lists are usually ordered by significance, so truncation is defensible — but it must be logged, and the truncation point should be stable between runs so the same rules are dropped each time.
1const SAFE_MAX = 28_000; // leave headroom under the 30,000 guarantee
2if (rules.length > SAFE_MAX) {
3 console.warn(`truncating ${rules.length - SAFE_MAX} rules from the tail of ${source}`);
4 rules.length = SAFE_MAX;
5}
Execution context: Node, during the build. The headroom matters: shipping exactly at the limit leaves no room for the dynamic rules a user creates, and those come out of a shared budget on some engines.
Cross-browser variation
- Chrome / Edge: the guaranteed static budget is 30,000 rules across enabled rulesets, plus any unused dynamic allowance. The regex allowance of 1,000 is shared across static, dynamic and session rules and is by far the easiest to exhaust.
- Firefox: implements the same API with its own, generally smaller ceilings, and Firefox extensions can still use blocking
webRequest— a large list may be better served there by the imperative API. - Safari: the effective ceiling is the lowest of the three and static rulesets are the only dependable store. Assume a few thousand rules, not tens of thousands, and verify on a real device.
- All three: exceeding a limit is a load-time failure, not a runtime one. Nothing throws while the extension runs — the rules simply are not there.
Verification
- After loading the extension, confirm every ruleset you expected is enabled:
1await chrome.declarativeNetRequest.getEnabledRulesets();
2// ["core", "annoyances"] ← a missing id means that file failed to load
Execution context: the service worker console. If an id is missing, open chrome://extensions and click Errors — Chrome names the rule index that failed validation.
- Add rules until a dynamic update rejects, and confirm your code surfaces the rejection to the user rather than swallowing it.
- Run the build check against a deliberately oversized list and confirm the pipeline fails.
FAQ
Does a disabled static ruleset count against the limit?
No. Only enabled rulesets count, which is exactly why shipping optional lists disabled is the cheapest way to offer more filtering than the budget allows at once.
Do allow rules cost the same as block rules?
Yes — the budget is a count of rules, not of work. An allowAllRequests rule on a frame is one rule regardless of how many sub-requests it exempts, which makes it an efficient way to express a whitelist.
What is the cheapest way to block a thousand domains?
One rule with a thousand entries in requestDomains. There is a separate cap on the total number of domain entries across rules, but it is far higher than the rule count, so this trade is almost always worth making.
Related
- Dynamic vs static rulesets — which budget each store draws from.
- Redirecting and rewriting headers with rules — the actions that also need host access.
- Debugging rules that don’t match — confirming a rule loaded at all.
- declarativeNetRequest rules — the parent guide to request filtering.