Dynamic vs Static Rulesets
Choose between static rule_resources, dynamic rules and session rules in declarativeNetRequest — how each is loaded, counted against the limits, and persisted across updates.
Table of Contents
declarativeNetRequest offers three places to put a rule and they behave nothing alike: static rules ship in the package and are re-read on update, dynamic rules are written at runtime and survive restarts, session rules vanish when the browser closes. Choosing the wrong one produces a filter list that either cannot be updated without a store review or silently forgets everything the user configured. This guide is part of declarativeNetRequest rules.
The three stores
Step-by-step
1. Ship the stable list statically
Rules that are part of your product — the blocklist everyone gets — belong in the package. They load before the first request of the session, with no worker start required.
1{
2 "permissions": ["declarativeNetRequest"],
3 "declarative_net_request": {
4 "rule_resources": [
5 {
6 "id": "core", // referenced by updateEnabledRulesets()
7 "enabled": true,
8 "path": "rules/core.json"
9 },
10 {
11 "id": "strict",
12 "enabled": false, // opt-in, toggled at runtime
13 "path": "rules/strict.json"
14 }
15 ]
16 }
17}
Execution context: parsed at install and on every update. A malformed rule file fails the whole ruleset silently in Chrome and logs to the extension’s error page — check it there rather than assuming it loaded.
2. Toggle whole static rulesets at runtime
You cannot edit a static rule, but you can turn a whole file on and off — which covers most “strict mode” style options without any dynamic rules at all.
1await chrome.declarativeNetRequest.updateEnabledRulesets({
2 enableRulesetIds: ["strict"],
3 disableRulesetIds: [],
4});
Execution context: the service worker or an extension page. The enabled set persists across restarts and updates, so read it back with getEnabledRulesets() rather than mirroring it in your own storage.
3. Write the user’s own rules dynamically
Dynamic rules are the right home for anything the user created. They persist across restarts and are carried through an extension update, so they must be treated as data you own forever — including migrating their shape, as in running data migrations on onInstalled.
1async function addUserBlock(pattern) {
2 const existing = await chrome.declarativeNetRequest.getDynamicRules();
3 const nextId = Math.max(0, ...existing.map((r) => r.id)) + 1;
4
5 await chrome.declarativeNetRequest.updateDynamicRules({
6 addRules: [{
7 id: nextId,
8 priority: 1,
9 action: { type: "block" },
10 condition: { urlFilter: pattern, resourceTypes: ["main_frame", "sub_frame", "script"] },
11 }],
12 removeRuleIds: [],
13 });
14}
Execution context: the service worker. Rule ids must be unique within the dynamic set and are yours to allocate; deriving the next id from the current maximum is safe because the call is serialised by the browser, but two concurrent calls from two contexts can still collide — keep all writes in the worker.
4. Use session rules for anything temporary
Session rules are cleared when the browser exits and are never carried through an update. That makes them ideal for a per-tab or “just for now” rule where leaking the state into the next session would be a bug.
1async function pauseBlockingForTab(tabId) {
2 await chrome.declarativeNetRequest.updateSessionRules({
3 addRules: [{
4 id: 1_000_000 + tabId, // keep session ids in their own range
5 priority: 100, // beats the static rules
6 action: { type: "allowAllRequests" },
7 condition: { tabIds: [tabId], resourceTypes: ["main_frame"] },
8 }],
9 removeRuleIds: [],
10 });
11}
Execution context: the service worker. tabIds conditions are only valid on session rules — a dynamic or static rule that uses them is rejected at registration time. The per-tab pattern is expanded in scoping rules to a single tab.
5. Keep ids in disjoint ranges
The three sets are evaluated together and priority ties are resolved by action type, not by id, but debugging is far easier when an id tells you where a rule came from.
1export const ID_RANGE = {
2 staticCore: [1, 99_999],
3 staticStrict: [100_000, 199_999],
4 dynamicUser: [200_000, 999_999],
5 session: [1_000_000, 1_999_999],
6};
Execution context: a shared constants module. The browser does not enforce this, but chrome.declarativeNetRequest.testMatchOutcome reports the matching rule id, and a disjoint scheme turns that number into an immediate answer.
Priority, action type, and who actually wins
Rules from all three stores are evaluated together, which means a rule you wrote last week in a static file can be silently overruled by one the user added this morning. The resolution order is worth committing to memory, because reasoning about a filter list without it is guesswork.
The browser picks a winner in two passes. First, the highest priority value wins. Second, where priorities tie, the action type decides, in a fixed order: allow and allowAllRequests beat block, which beats redirect and upgradeScheme, which beat modifyHeaders. modifyHeaders is the exception to the whole scheme — header rules are not exclusive, so several can apply to one request, and they apply in descending priority order.
That gives a simple layering convention worth adopting from the start:
1export const PRIORITY = {
2 shippedBlock: 1, // your default list
3 shippedAllow: 10, // your own exceptions to it
4 userBlock: 100, // the user's additions
5 userAllow: 1000, // the user's exceptions — always win
6 sessionPause: 10000, // temporary overrides
7};
Execution context: a shared constants module used by whatever generates each store’s rules. Leaving gaps between the tiers means a future layer can be inserted without renumbering, and a rule whose priority is not from this table is immediately suspicious in a report.
The most common surprise this explains: a user adds an allow rule for a site, and it does nothing, because the shipped block rule was written with priority: 1000 “to make sure it works”. Priority inflation in the shipped list is the cause of most “my exception is ignored” reports, and the fix is to lower the shipped rules rather than to raise the user’s.
Cross-browser variation
- Chrome / Edge: all three stores are available. The guaranteed minimum is 5,000 dynamic rules and 5,000 session rules, with static rules counted against a much larger enabled-ruleset budget — the arithmetic is covered in staying under the rule count limits.
- Firefox: supports static and dynamic rules; session rules arrived later and some conditions, notably
tabIds, behave differently. Firefox also still supports blockingwebRequestfor extensions that need it, so a Firefox build may not needdeclarativeNetRequestat all. - Safari: static rulesets are the reliable path. Dynamic rule support exists but has a lower effective ceiling and is slower to apply after a browser start, so treat dynamic rules as best-effort and keep the product’s core behaviour static.
- All three: none of the stores can express “block unless the user clicked something”. That is a session rule plus your own state, never a condition.
Verification
- Count what is actually registered in each store from the service worker console:
1({
2 static: (await chrome.declarativeNetRequest.getEnabledRulesets()),
3 dynamic: (await chrome.declarativeNetRequest.getDynamicRules()).length,
4 session: (await chrome.declarativeNetRequest.getSessionRules()).length,
5});
6// { static: ["core"], dynamic: 12, session: 1 }
Execution context: the service worker console. If static is empty when you expected a ruleset, the JSON failed validation — open chrome://extensions, click Errors, and read the rule index it names.
- Restart the browser and re-run the same expression. Static and dynamic counts should be unchanged; session should be zero.
- Install an update over the top and confirm dynamic rules survived while any session rule did not.
FAQ
Can a dynamic rule override a static one?
Yes — matching is by priority first, then by action type, with allow and allowAllRequests beating block at equal priority. Give user rules a higher priority number than your shipped list so an explicit allow always wins.
Do dynamic rules need their own permission?
No, declarativeNetRequest covers all three stores. Redirecting or modifying headers does require declarativeNetRequestWithHostAccess or matching host permissions — see redirecting and rewriting headers with rules.
What happens to dynamic rules if the user reinstalls?
They are gone: a reinstall is a fresh profile-level install with empty dynamic and session stores. Keep the user’s rule list mirrored in chrome.storage.sync and re-apply it on onInstalled so a reinstall restores it.
Related
- Staying under the rule count limits — the budget each store draws from.
- Scoping rules to a single tab — what session rules make possible.
- Debugging rules that don’t match — finding out which store answered a request.
- declarativeNetRequest rules — the parent guide to request filtering in MV3.