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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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

Guaranteed rule budgets in ChromeRule count allowances for enabled static rulesets, dynamic rules, session rules and the shared regular-expression allowance.Static, across enabled rulesets30000 rulesplus unused dynamic headroomDynamic rules5000 rulesSession rules5000 rulesRegex rules (all stores)1000 rulesthe one that bites
The regex budget is shared across all three stores, which is why a few hundred regexFilter rules can starve a large list.

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.

Compressing a forty-thousand-entry listA raw filter list is normalised, domain-only entries are merged into requestDomains rules, regex entries are rewritten as urlFilter where possible, and the remainder is split into opt-in rulesets.Raw list40,000 entriesNormalisededupe, drop commentsMerge domainsrequestDomainswhat survives is patterns, not domainsRewrite regexurlFilter where equivalentSplit by localeopt-in rulesetsAssert in CIfail the build
In practice the first two steps remove most of the volume; the split is what handles what is left.

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.

A rule pipeline that cannot quietly overflowAn upstream list is fetched, normalised and compiled to rules with hashed ids, counted against the budget, truncated with a warning if needed, and asserted in CI.Fetch upstream listpinned revisionNormalise + dedupedrop commentsCompile to ruleshashed idsthen account for every ruleCount per storestatic / regexTruncate with a warningstable tailAssert in CIfail over budget
The count and the assertion are what turn a silent load failure into a red build.

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

  1. 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.

  1. Add rules until a dynamic update rejects, and confirm your code surfaces the rejection to the user rather than swallowing it.
  2. 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.

Other Core APIs & Cross-Browser Data Management Resources