What Chrome's rule limit actually counts
“I keep reading that Manifest V3 blockers are crippled by Chrome's rule limit, and I want to know what that limit actually restricts.”
A blocker written against Chrome's declarative API does not get asked about each request. It hands the browser its rules in advance, the browser matches them itself, and the extension need not be running while that happens. The rules therefore have to be counted, and anything counted has a ceiling.
That ceiling comes up whenever people argue about whether a Manifest V3 blocker is good enough, usually as a single number. This post is about what the number counts and what happens at it. The short version is that it counts rules rather than names, that a rule is whatever the list compiler decides to put in one, and that crossing the line is usually not an error.
Four limits, and they fail differently
There is not one limit, there are several, and the interesting part is that they do not fail alike.
Too many rulesets in the manifest is fatal. Chromium's manifest handler for the API compares the declared list against the maximum and gives up on the whole extension:
if (rulesets.size() >
static_cast<size_t>(dnr_api::MAX_NUMBER_OF_STATIC_RULESETS)) {
*error = ErrorUtils::FormatErrorMessageUTF16(...);
return false;
}
The same shape follows further down for the number of rulesets marked enabled. Both
return false out of Parse(), which means the extension does not load at all.
Too many rules inside one ruleset file is not fatal, and the file goes whole. The limit
for a single static file is large, and a file over it is not read at all. The extension
gets the message "Ruleset with id * exceeds the indexing rule limit and will be ignored.", and the code gives its reason in a comment: such a ruleset "can never be
enabled in its entirety".
The parser does have a loop that reads rules until a budget runs out and then stops, with
the warning "Rule count exceeded. Some rules were ignored." For a static file it cannot
trigger, because the whole-file check has already returned before the loop starts. It is
there for dynamic rules, the ones an extension adds at runtime.
Two kinds of rule are skipped one at a time instead. Regular expression rules over their
own limit are each passed over with continue, and the warning is added only once, for
the first. Rules the browser cannot parse at all are skipped too, and the comment above
the loop is explicit that this is a decision rather than an oversight:
We don't use json_schema_compiler::util::PopulateArrayFromList since it fails if a single Value can't be deserialized. However we want to ignore values which can't be parsed to maintain backwards compatibility.
Too many enabled rules at load time drops whole rulesets, the later ones first. This is the case that matters most to someone switching lists on. When the browser loads an extension, it adds up the enabled static rulesets in the order the manifest lists them, against the extension's allowance. A ruleset that would take the total over is skipped, and the loop moves on to the next. The comment says why it is not an error:
Note: we don't enforce the rules limit at install time (by raising a hard error) to maintain forwards compatibility. Since we iterate based on the order of ruleset ID, we'll give more preference to rulesets occurring first in the manifest.
The warning that results is "The number of enabled rules exceeds the API limits. Some rulesets will be ignored." Nothing is cut short inside a list. A list is either all in or
all out, and which one is out depends on where it sits in the manifest.
Too many rules through the API at runtime is an error. When an extension calls
updateEnabledRulesets to switch a list on, the service checks the ruleset count, the
regex count and the rule allocation before applying anything, and on failure hands back a
message instead: "The set of enabled rulesets exceeds the rule count limit." Nothing is
truncated there. The request simply does not happen.
So the same overage is a dead extension, a list that is missing whole, or a rejected call, depending only on which door the rules came in through.
What "warning" means here
It is worth being precise about the word, because the type Chromium uses carries a definition in its own header:
A struct to describe a non-fatal issue discovered in the installation of an extension.
Non-fatal is the whole point. The extension installs, loads, runs, and shows every sign of working, because by its own lights it is working. A ruleset that was skipped is missing from the matcher, and nothing at request time will ever mention it again.
Whether a person running the blocker is ever shown that warning is a question about Chrome's UI, and it is one this post did not test.
A rule is not a hostname
Here is where the number stops meaning what it sounds like it means.
uBO Lite, the declarative build of uBlock Origin, ships its filter lists as rule files
inside the extension. Its Chromium manifest declares 56 static rulesets, of which 6 are
marked enabled by default: ublock-filters, easylist, easyprivacy, pgl,
ublock-badware and urlhaus-full.
Those six files, downloaded and counted, hold 18,590 rules between them. Of those
rules, 927 carry a requestDomains condition, and those 927 lists hold 107,731 domain
entries. The ratio is not an accident, it is the compiler doing its job: a rule may name
a list of domains, so thousands of hostnames can be blocked by one rule.
The extreme case is easy to find. In easylist.json, rule id 1 is a plain block action
whose condition has exactly one key, requestDomains, with 48,868 entries in it. That
single rule serializes to just over a megabyte of JSON. Against the ceiling, it counts as
one. easyprivacy.json opens the same way, with a first rule of 43,095 domains.
Two files in the default set are a single rule each: pgl is one rule listing 3,456
domains, urlhaus-full is one rule listing 3,575.
The parser checks that the list is not empty (ERROR_EMPTY_REQUEST_DOMAINS_LIST) and that
its entries are ASCII (ERROR_NON_ASCII_REQUEST_DOMAIN), and nothing in the parsing path
caps how long it may be. Nor is file size the scarce thing. The maximum ruleset file size
in Chromium is std::numeric_limits<int>::max() - 1, which is about two gigabytes, so a
one megabyte rule is nowhere near it.
That is the real shape of the constraint. It does not bound how much of the web a blocker can cover. It bounds how many separately conditioned decisions it can express, which is a different and much more particular thing, and it rewards a compiler that can flatten many names into few conditions.
The headroom is shared with other extensions
The per-extension figure is only a floor. Chrome's documentation calls it
GUARANTEED_MINIMUM_STATIC_RULES and puts it at 30,000 rules, collectively, across an
extension's enabled rulesets. Above that floor, rules come from somewhere else.
Chromium keeps a profile-wide pool, kMaxStaticRulesPerProfile, set in the source to
300,000, and tracks how much of it each installed extension has been allocated. The
accounting is the point:
// If updating this extension's rule count would cause the global rule count
// to be exceeded, don't commit the update and return false.
if (new_global_rule_count > static_cast<size_t>(GetGlobalStaticRuleLimit())) {
return false;
}
That tracker walks every installed extension to total up what has already been handed out. So the number of rules your blocker can have enabled is not a property of your blocker. It is a property of your profile, and another extension that also uses the API draws its allocation from the same 300,000.
The per-ruleset indexing limit, the one that drops a file whole, is the sum of the two: 30,000 plus 300,000, so 330,000 rules in a single file.
Numbers make this concrete. uBO Lite's six default rulesets come to 18,590 rules, which
sits under the 30,000 floor with 11,410 to spare, so a default install needs none of the
shared pool. All 56 rulesets it ships come to 70,285 rules. Enabling everything would
be 40,285 rules above the floor, or about 13 percent of the whole profile pool, for one
extension. There is an API for exactly this, and its existence is the admission: an
extension is expected to call getAvailableStaticRuleCount() and ask how much room it has
left, because it cannot know. uBO Lite's own code wraps its enable call in a catch, logs
the reason, and returns the failure to its caller rather than assuming the switch took
effect.
The blocker ends up keeping its own books
One more thing falls out of the shipped files, and it is the clearest evidence of what living under these limits does to a design.
None of those 56 static ruleset files contains a single regular expression rule. That is
not because uBO Lite has none. Its regex rules live in a parallel set of files under
rulesets/regex/, fetched at runtime and installed as dynamic rules instead, which are
counted against a different ceiling. For the six default lists, those sidecar files hold
249 regex rules between them.
Having moved them, the extension ends up keeping books of its own. Before it installs the sidecar rules it checks each regular expression and drops the ones the browser says it cannot support, and logs the rejects. And it has a second source of regex rules, the session rules behind its strict blocking mode, which it budgets against whatever the dynamic rules have already used, without spending the whole allowance:
const maxRegexCount = dnr.MAX_NUMBER_OF_REGEX_RULES * 0.95;
Session rules past that self-imposed 95 percent are marked and filtered out before the
call, with a line in the extension's log: Too many regex-based filters, ${rejectedRuleCount} session rules dropped.
Look at what that costs. Rules over a limit get dropped either way. The difference is that the extension drops them deliberately, keeps a margin so the platform never has to, and writes down that it happened. The blocker is keeping its own accounting in parallel with the platform's, because it wants to know the answer rather than find out from a failed call.
If you are the one switching lists on
The practical reading is narrow, and worth keeping narrow.
Turning on more lists in a declarative blocker is a request, not a setting. It can be refused, and the refusal happens at a layer well below the checkbox. The signal you get from the interface is that you asked, which is not the same as the rules being in the matcher. And when the allowance runs short at load time, what goes missing is a whole list, chosen by its position in the extension's manifest, not by anything you picked.
And there is a measurement trap here worth naming: rule counts are not coverage. An extension with 5,000 rules and a good compiler may block more hostnames than one with 25,000 rules and a naive one, because one of them is writing 48,868 domains into a single condition and the other is writing 48,868 rules. Comparing blockers by how close they run to the limit tells you about their compilers, not about their lists.
The boundary of what was checked
Everything above about Chrome's behaviour was read from Chromium's own source in the
extensions/ tree: the manifest handler, the ruleset parser, the constants file that holds
the warning strings, the global rules tracker, the rules monitor service, the limit
accessors and the install warning header. The quoted strings and code are verbatim from
those files.
Four of the numbers are constants of the API itself: 30,000 guaranteed minimum static
rules, 50 enabled rulesets out of 100, and 1,000 regular expression rules. Those are read
from the API definition in the Chromium tree, declarative_net_request.webidl, and agree
with the source file behind Chrome's own documentation page. Neither of those gives the
300,000 figure: the documentation says only that the room above 30,000 "depends on how
many rules are enabled by all the extensions installed on a user's browser".
kMaxStaticRulesPerProfile at 300,000 and the ruleset file size limit are read straight
from the Chromium source. The 330,000 figure is those two added together, which is how the
comment in the parser defines the indexing limit.
The uBO Lite figures were counted, not quoted: the 56 ruleset paths were taken from the shipped Chromium manifest, every file was downloaded, and the rules, domain entries and regex rules were counted in them. Anyone can repeat that. The totals move with every list update, so treat them as a measurement taken on 21 September 2026 rather than as constants.
No browser was run for this post. Nothing here was verified by installing an extension and watching it hit a limit, which means the claims are about what the code does rather than about what any particular Chrome did. This post also makes no claim about any browser's timetable for changing its extension platform, does not compare blockers by effectiveness, and does not say which of uBO Lite's rulesets anyone should enable.