Debugging declarativeNetRequest Rules That Don't Match
Diagnose why a declarativeNetRequest rule silently fails — priority conflicts, urlFilter syntax, resourceTypes, host permissions, allow vs allowAllRequests, and the rules tester.
Diagnose why a declarativeNetRequest rule silently fails — priority conflicts, urlFilter syntax, resourceTypes, host permissions, allow vs allowAllRequests, and the rules tester.
A declarativeNetRequest rule that compiles without error but never fires is one of the most frustrating problems in MV3 development. The API gives no runtime exception, no console warning, and no obvious signal — the request simply passes through as if the rule were not there. This page walks through every common silent-failure cause, explains the underlying MV3 execution model, and shows the debugging tools that reveal what is actually happening.
Parent reference: Declarative Net Request Rules
The declarativeNetRequest API hands request matching to the browser’s native networking layer, outside the JavaScript event loop. Rules are evaluated before your service worker ever wakes up. Because no JavaScript runs during the match phase, there is nowhere to attach a try/catch or log a diagnostic message. When a rule does not match, the engine moves on silently. Silent failures fall into two categories: the rule is never consulted (wrong URL filter, wrong resource type, missing host permission) or the rule is consulted but loses to a higher-priority competing rule (an allow or allowAllRequests action overrides it). Understanding which category applies determines which debugging strategy to use.
Before debugging match logic, verify the rule exists in the engine. An update that failed mid-flight or a dynamic rule added in a code path that did not run will silently leave the ruleset empty.
1// service-worker.js
2async function logRegisteredRules() {
3 const dynamic = await chrome.declarativeNetRequest.getDynamicRules();
4 const session = await chrome.declarativeNetRequest.getSessionRules();
5 console.log("Dynamic rules:", JSON.stringify(dynamic, null, 2));
6 console.log("Session rules:", JSON.stringify(session, null, 2));
7}
8
9logRegisteredRules();
Execution context: Service worker — call from the DevTools console of the inspected service worker or add temporarily at the top level to run on startup.
If the rule you expect is missing, the problem is in your addDynamicRules / updateSessionRules call, not in match logic. Fix registration first.
urlFilter is not a standard glob and is not a regular expression. It uses its own pattern language, and the most common mistake is treating it like one of the others.
Key anchors and tokens:
|| — domain anchor; matches the scheme separator and any subdomain prefix.^ — separator anchor; matches any character that is not a letter, digit, or _-%.| at start or end — exact string anchor at the URL boundary.* — wildcard matching any sequence of characters. 1// BROKEN: treats the filter like a URL glob
2{ urlFilter: "*://example.com/*" }
3// This matches the literal characters "*://example.com/*" — the leading
4// "*" wildcard does work, but "*://" is redundant noise and will miss
5// "https://sub.example.com/path" because there is no subdomain wildcard.
6
7// FIXED: use domain anchor + separator anchor
8{ urlFilter: "||example.com^" }
9// Matches https://example.com/, https://sub.example.com/path,
10// http://example.com/any?query — all schemes, all subdomains.
Execution context: Rule JSON evaluated at load time by the browser engine, not inside a JavaScript runtime.
For exact scheme + host matching use |https://example.com|. For path-prefixed matching combine the domain anchor with a literal path: ||example.com^/api/.
If resourceTypes is omitted, the rule matches requests of every type. If it is specified but lists the wrong type, the rule silently skips every request that does not match. The complete set of valid string values is:
main_frame, sub_frame, stylesheet, script, image, font, object, xmlhttprequest, ping, csp_report, media, websocket, webbundle, other
1// BROKEN: typo causes the rule to never match XHR/fetch requests
2{
3 id: 1,
4 priority: 1,
5 action: { type: "block" },
6 condition: {
7 urlFilter: "||analytics.example.com^",
8 resourceTypes: ["xhr"] // ← invalid; should be "xmlhttprequest"
9 }
10}
11
12// FIXED
13{
14 id: 1,
15 priority: 1,
16 action: { type: "block" },
17 condition: {
18 urlFilter: "||analytics.example.com^",
19 resourceTypes: ["xmlhttprequest", "ping"]
20 }
21}
Execution context: Rule JSON evaluated at load time by the browser engine.
declarativeNetRequest rules that perform redirect or modifyHeaders actions require that the extension holds a matching host_permissions entry for the destination URL. Without it the browser silently skips the rule. The declarativeNetRequest permission alone is not sufficient.
1// manifest.json — BROKEN: no host_permissions for the target domain
2{
3 "permissions": ["declarativeNetRequest"],
4 "host_permissions": []
5}
6
7// manifest.json — FIXED
8{
9 "permissions": ["declarativeNetRequest"],
10 "host_permissions": ["*://*.example.com/*"]
11}
Execution context: Manifest parsed by the browser at install/update time. A permissions change requires re-installing or updating the extension.
Simple block rules do not require host permissions in Chrome, but adding them anyway is safer for cross-browser compatibility and avoids confusion when adding redirect rules later.
The browser applies actions in a strict precedence order when multiple rules match the same request. From highest to lowest priority:
allowAllRequestsallowblockupgradeSchemeredirectmodifyHeadersWithin the same action type, the rule with the numerically higher priority value wins. This means a single allow rule with priority: 2 will silently defeat a block rule with priority: 1, even if the block rule was added later and feels more specific.
1// These two rules exist in the same ruleset.
2// The allow rule wins because "allow" outranks "block" in action precedence,
3// regardless of priority numbers within different action tiers.
4const conflictingRules = [
5 {
6 id: 10,
7 priority: 1,
8 action: { type: "allow" }, // ← wins because of action precedence
9 condition: { urlFilter: "||example.com^" }
10 },
11 {
12 id: 11,
13 priority: 100, // ← higher number, but block loses to allow
14 action: { type: "block" },
15 condition: { urlFilter: "||example.com^" }
16 }
17];
18
19// FIX: remove the conflicting allow rule, or raise the block rule to the
20// allow tier by restructuring intent — e.g. remove the allowAllRequests
21// rule that was added for a different purpose.
Execution context: Priority resolution happens in the browser’s network interception layer before any JavaScript runs.
An allowAllRequests rule is especially dangerous because it exempts the matched main_frame request and cascades to every sub-resource loaded by that page, including scripts, images, XHR, and fetch. A single allowAllRequests rule targeting ||example.com^ will silently exempt everything on that site, regardless of how many block rules you add for sub-resources.
Register this listener at the top level of your service worker so it survives cold starts. It fires for every rule match while the extension is in developer mode with the declarativeNetRequestFeedback permission.
1// service-worker.js — top level, outside any function
2if (chrome.declarativeNetRequest.onRuleMatchedDebug) {
3 chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => {
4 console.log(
5 `[DNR matched] rule ${info.rule.ruleId} in ruleset "${info.rule.rulesetId}"`,
6 `| tab ${info.request.tabId}`,
7 `| ${info.request.method} ${info.request.url}`
8 );
9 });
10}
Execution context: Service worker top level. The listener must be registered synchronously before the service worker reaches idle, or re-registered every time the worker cold-starts.
Required setup: add "declarativeNetRequestFeedback" to permissions in manifest.json, and load the extension in Chrome with Developer mode enabled in chrome://extensions. Without both conditions the event object exists but the listener never fires.
getMatchedRules() returns rules that matched within a rolling five-minute window. It is useful for post-hoc inspection when you cannot watch the console in real time.
1// service-worker.js
2async function checkRecentMatches(tabId) {
3 const result = await chrome.declarativeNetRequest.getMatchedRules({
4 tabId, // omit to query all tabs
5 minTimeStamp: Date.now() - 5 * 60 * 1000 // last 5 minutes
6 });
7
8 for (const match of result.rulesMatchedInfo) {
9 console.log(
10 `Rule ${match.rule.ruleId} matched`,
11 `"${match.request.url}"`,
12 `at ${new Date(match.timeStamp).toISOString()}`
13 );
14 }
15}
Execution context: Service worker. Call from the DevTools console of the inspected service worker: checkRecentMatches(/* tabId */);
If the list is empty but you expected a match, the rule either did not fire (wrong filter, wrong resource type, missing permission) or an allow/allowAllRequests action won first — getMatchedRules only reports the winning rule, not every evaluated candidate.
The DevTools Network panel shows blocked requests with status (blocked) or (canceled) in the Status column. Filter by blocked: in the filter bar to isolate them. Click any blocked request and check the Headers tab — Chrome reports which extension blocked it.
For an interactive rules tester: open chrome://extensions, enable Developer mode, click your extension’s detail card, and look for the “Test” button next to the ruleset list. This UI lets you enter a URL, method, and resource type, then shows which rule would match and what action it would take — without making a real request.
onRuleMatchedDebug is available when developer mode is enabled and declarativeNetRequestFeedback is in permissions. getMatchedRules() works with the five-minute rolling window. Regex rules (regexFilter) are supported with some complexity limits. The interactive rules tester is built into chrome://extensions.onRuleMatchedDebug nor getMatchedRules() is implemented. Use the Firefox DevTools Network Monitor — it shows "blocked" entries with the blocking extension identified. Static rule limit is 5,000 per extension (Chrome allows 30,000). Some regexFilter patterns that Chrome accepts are rejected by Firefox’s engine. Dynamic and session rules work but have a combined limit of 5,000 by default.onRuleMatchedDebug and no getMatchedRules(). Rely on Safari Web Inspector’s Network tab to observe blocked or cancelled requests. Safari is stricter about host_permissions pattern matching — wildcards that Chrome accepts may not satisfy Safari’s permission checker, causing redirect and modifyHeaders rules to silently skip.To confirm a fix is working end-to-end:
"declarativeNetRequestFeedback" to permissions in manifest.json.onRuleMatchedDebug listener at the service worker top level (see step 6 above).chrome://extensions, enable Developer mode, and click “Inspect views: service worker” for your extension.[DNR matched] log line with the correct ruleId.Cross-check with getDynamicRules():
1// Run in the service worker console to confirm the rule is present
2chrome.declarativeNetRequest.getDynamicRules().then(rules =>
3 console.log("Registered dynamic rules:", rules)
4);
Execution context: Service worker DevTools console.
In the DevTools Network panel, the blocked request should appear with status (blocked) and, when clicked, show your extension name in the response headers section under “Blocked by”.
Firefox enforces a static ruleset limit of 5,000 rules per extension, compared to Chrome’s 30,000. If your static ruleset exceeds that limit, Firefox silently drops rules beyond the cap — and the rule that works in Chrome may simply not have been loaded. Also check regexFilter patterns: Firefox rejects some syntactic constructs that Chrome accepts, and an invalid regex silently disables that rule in Firefox while leaving it active in Chrome. Validate your regex patterns against Firefox’s implementation separately.
Priority numbers only break ties between rules of the same action type. An allow rule with priority 1 always beats a block rule with priority 100 because allow is higher in the action precedence hierarchy. Similarly, an allowAllRequests rule with any priority beats every allow, block, redirect, and modifyHeaders rule for both the matched frame and its sub-resources. Run getMatchedRules() or watch onRuleMatchedDebug — the event reports the winning rule, which will identify the conflicting allow or allowAllRequests entry.
Three conditions must all be true simultaneously: (1) the extension must declare "declarativeNetRequestFeedback" in permissions in manifest.json; (2) the extension must be loaded in a Chrome profile with Developer mode enabled in chrome://extensions; (3) the listener must be registered at the service worker top level before the worker goes idle. If the service worker cold-starts (e.g. after being idle for 30 seconds), the listener is gone until the worker restarts and runs the top-level registration code again. Reload the extension after making any of these changes, because permission updates are not hot-applied.
Yes, with caveats. getDynamicRules() and getSessionRules() always reflect the current live state — no reload needed to see rules you added via addDynamicRules. The onRuleMatchedDebug event fires for any new request after the listener is registered, also without reloading. Static rulesets declared in manifest.json do require a reload to pick up changes to the JSON files. Dynamic and session rules can be added, updated, or removed live and take effect on the next matching request.
Chrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.
Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.
Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.