Declarative Net Request Rules

Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.

In MV2, extensions could intercept every network request in JavaScript and mutate it synchronously — a model that allowed ad blockers and privacy tools to work but also let extensions stall page loads indefinitely and inspect sensitive request bodies. Manifest V3 removes that capability entirely. Any extension still relying on chrome.webRequest with the blocking extraInfoSpec will find those listeners silently ignored. The replacement is chrome.declarativeNetRequest (DNR): a declarative rule engine built into the browser process that evaluates rules without invoking extension JavaScript at all. This guide is part of Core APIs & Cross-Browser Data Management.

The first thing that trips developers: DNR rules do not fire simply because you defined them. A rule silently does nothing if your extension lacks a matching host permission, if a higher-priority allow or allowAllRequests rule is already in effect, or if the resourceTypes condition doesn’t align with what the browser classifies the request as. Understanding the rule-matching pipeline is what separates rules that work from rules that silently fail.

declarativeNetRequest rule-matching flowIncoming requests flow through URL filter, resource type, host permission, and priority resolution checks before a matched action is applied.Incoming Requestany URL / resource typeurlFilter / regexFilter|| anchor · ^ separator · * globresourceTypes conditionscript · xmlhttprequest · main_frame…host_permissions checksilent skip if no permissionPriority resolutionhighest int wins; allow beats blockblockredirectmodifyHeadersallow / allowAllRequests

Prerequisites checklist

Before writing your first rule, verify these are already in place:

  • declarativeNetRequest permission in manifest.json — required for all rule evaluation. Add declarativeNetRequestFeedback in development builds to unlock getMatchedRules and onRuleMatchedDebug.
  • host_permissions covering every domain your rules target — rules for URLs outside your host permissions are silently skipped, not rejected.
  • Rule ID strategy — IDs must be unique positive integers across dynamic and session rules (static ruleset IDs are separate). Plan a numeric namespace before you have 100+ rules.
  • Action precedence awarenessallowAllRequests > allow > block > upgradeScheme > redirect > modifyHeaders. A single high-priority allow rule can defeat every block rule below it.

1. Declare the manifest surface

Static rulesets are declared in the declarative_net_request manifest key. The browser loads and compiles them before any extension JavaScript runs. For user-configurable rules, add the permissions array entry; the rule_resources key registers static JSON files.

 1{
 2  "manifest_version": 3,
 3  "name": "DNR Demo",
 4  "permissions": [
 5    "declarativeNetRequest",
 6    "declarativeNetRequestFeedback" // dev builds only — omit in production
 7  ],
 8  "host_permissions": ["<all_urls>"],
 9  "declarative_net_request": {
10    "rule_resources": [
11      {
12        "id": "baseline_rules",
13        "enabled": true,
14        "path": "rules/baseline.json"
15      }
16    ]
17  }
18}

Execution context: Parsed by the browser host at install and update time, before the background service worker initialises. Chrome and Edge accept the full schema; Firefox accepts declarative_net_request from Manifest V3 but enforces its own static-rule quota. Safari requires NSExtensionPointIdentifier set to com.apple.Safari.web-content-filter for content-blocking rules.

2. Rule structure: id, priority, condition, action

Every rule is a JSON object with four required top-level keys. The condition and action objects carry the real logic. Keep IDs as stable positive integers — changing a static rule’s ID in a bundled update removes the old rule and registers a new one, which matters for users who have exceptions built on top.

 1// rules/baseline.json
 2[
 3  {
 4    "id": 1,
 5    "priority": 10,
 6    "condition": {
 7      "urlFilter": "||tracker.example.com^",   // || = domain anchor, ^ = separator wildcard
 8      "resourceTypes": ["script", "image", "xmlhttprequest"]
 9    },
10    "action": {
11      "type": "block"
12    }
13  },
14  {
15    "id": 2,
16    "priority": 5,
17    "condition": {
18      "urlFilter": "||cdn.example.com^",
19      "resourceTypes": ["script"],
20      "initiatorDomains": ["trusted.app"]      // only fires when initiated from this domain
21    },
22    "action": {
23      "type": "allow"                          // exempts requests matching this condition
24    }
25  }
26]

Execution context: Static rules are evaluated entirely in the browser’s networking layer — no extension JavaScript executes during matching. initiatorDomains and requestDomains are Chrome 101+ / Firefox 128+. Omitting resourceTypes matches all resource types, which is almost never what you want for precise blocking.

3. modifyHeaders action

Header modification is the most nuanced action type. You can operate on both request and response headers. Each header entry specifies a header name, an operation (append, set, or remove), and for append/set a value. The rule needs the declarativeNetRequest permission; modifying response headers for cross-origin requests may also require appropriate CORS headers to already be present.

 1{
 2  "id": 10,
 3  "priority": 20,
 4  "condition": {
 5    "urlFilter": "||api.internal.example.com^",
 6    "resourceTypes": ["xmlhttprequest", "fetch"]
 7  },
 8  "action": {
 9    "type": "modifyHeaders",
10    "requestHeaders": [
11      { "header": "X-Extension-Token", "operation": "set", "value": "v3-auth" },
12      { "header": "X-Debug-Source",    "operation": "append", "value": "mv3-ext" }
13    ],
14    "responseHeaders": [
15      { "header": "Access-Control-Allow-Origin", "operation": "set", "value": "*" }
16    ]
17  }
18}

Execution context: Header modification runs in the browser’s networking thread before JavaScript in the page or worker sees the request or response. Firefox 128+ supports modifyHeaders for static rules; dynamic modifyHeaders requires Firefox 128+ as well. Safari 18+ supports a subset of response header modification.

4. redirect action

The redirect action routes a matching request to a different URL. It supports four sub-forms: url (absolute URL), extensionPath (a path inside your extension package), regexSubstitution (capture-group rewrite paired with a regexFilter condition), and transform (selective URL component replacement).

 1{
 2  "id": 20,
 3  "priority": 15,
 4  "condition": {
 5    "urlFilter": "||ads.example.com/banner*",
 6    "resourceTypes": ["image", "sub_frame"]
 7  },
 8  "action": {
 9    "type": "redirect",
10    "redirect": {
11      "extensionPath": "/assets/placeholder.png"
12    }
13  }
14}

Execution context: extensionPath redirects are the safest form — the target is always your own bundled asset. Redirecting to external url requires that the target URL’s origin is covered by host_permissions. Regex substitution is Chrome/Edge only; Firefox ignores regexSubstitution. The redirect action counts as an “unsafe” rule against the 5,000 unsafe-rule quota within dynamic rules.

5. Dynamic rules with updateDynamicRules

Static rulesets are fast but immutable between extension updates. For user-configurable filters — blocked domains, custom headers, allow-list entries — use chrome.declarativeNetRequest.updateDynamicRules. Dynamic rules persist across browser restarts but are scoped to the current extension version.

 1// background service worker — top-level or inside an event handler
 2async function applyUserBlocklist(domains: string[]): Promise<void> {
 3  const existing = await chrome.declarativeNetRequest.getDynamicRules();
 4  const obsoleteIds = existing.map(r => r.id);
 5
 6  const newRules: chrome.declarativeNetRequest.Rule[] = domains.map((domain, i) => ({
 7    id: 1000 + i,           // start IDs in a separate namespace from static rules
 8    priority: 50,
 9    condition: {
10      urlFilter: `||${domain}^`,
11      resourceTypes: ['main_frame', 'sub_frame', 'script', 'image', 'xmlhttprequest']
12    },
13    action: { type: 'block' }
14  }));
15
16  await chrome.declarativeNetRequest.updateDynamicRules({
17    removeRuleIds: obsoleteIds,
18    addRules: newRules
19  });
20}

Execution context: Runs in the service worker. getDynamicRules() returns the current live set — always call it before adding to avoid duplicate-ID errors. Chrome allows up to 30,000 dynamic rules total, with 5,000 of those being “unsafe” types (redirect, modifyHeaders). Firefox caps dynamic rules at 5,000. If you need to store the user’s original blocklist, persist it to Chrome Storage API & Sync — dynamic rules are lost if the extension is disabled and re-enabled.

6. Session rules

Session rules behave like dynamic rules but are cleared when the browser session ends (or the extension reloads). They are ideal for ephemeral debugging state or per-session user preferences that don’t need to survive a restart.

 1// Inject a session-scoped debug header — gone on next browser restart
 2await chrome.declarativeNetRequest.updateSessionRules({
 3  removeRuleIds: [9001],
 4  addRules: [{
 5    id: 9001,
 6    priority: 100,
 7    condition: {
 8      urlFilter: '||staging.internal.example.com^',
 9      resourceTypes: ['xmlhttprequest']
10    },
11    action: {
12      type: 'modifyHeaders',
13      requestHeaders: [
14        { header: 'X-Debug-Session', operation: 'set', value: 'true' }
15      ]
16    }
17  }]
18});

Execution context: Session rules are stored in memory, not persisted to disk. They require the same declarativeNetRequest permission as dynamic rules. getSessionRules() returns the current active set. The session-rule quota is 5,000 rules (Chrome 116+). Firefox does not yet support session rules.

7. Debugging with getMatchedRules

When a rule silently fails to match, the first tool to reach for is chrome.declarativeNetRequest.getMatchedRules. It returns records of which rules fired against which URLs in the last five minutes. Add declarativeNetRequestFeedback to your development manifest; omit it from production builds. The full debugging workflow — priority conflicts, urlFilter syntax errors, missing host permissions — is covered in debugging rules that don’t match.

1// Inspect recent rule matches (requires declarativeNetRequestFeedback permission)
2const result = await chrome.declarativeNetRequest.getMatchedRules({ tabId: -1 });
3for (const match of result.rulesMatchedInfo) {
4  console.log(
5    `Rule ${match.rule.ruleId} (ruleset: ${match.rule.rulesetId})`,
6    `matched ${match.request.url} at ${new Date(match.timeStamp).toISOString()}`
7  );
8}

Execution context: Available in service worker and extension pages. Requires declarativeNetRequestFeedback permission and the extension must be in developer mode (unpacked) or be in a testing profile. Pass tabId to scope results to one tab, or omit for all tabs. Chrome only; Firefox and Safari do not implement getMatchedRules.

MV3 Constraints box

  • Static rules: up to 30,000 per extension globally (Chrome); Firefox caps at 5,000.
  • Dynamic rules: up to 30,000 total, of which at most 5,000 may be “unsafe” (redirect, modifyHeaders).
  • Session rules: up to 5,000; cleared on browser restart or extension reload.
  • Regex rules: across all scopes combined, at most 1,000 rules may use regexFilter.
  • Priority range: 1 to 2,147,483,647 (int32 max); higher integer always wins within the same action tier.
  • No request body access: DNR rules cannot read or modify request or response bodies. Body inspection requires chrome.debugger or a native messaging host.
  • No dynamic JavaScript evaluation: rules are data, not code. Complex conditional logic must be split into multiple discrete rules or handled with session rules injected by service worker logic.
  • URL filter length: maximum 4,096 characters per filter string.

Cross-browser notes

Chrome ships the most complete DNR implementation. Firefox 128+ added the core static and dynamic rule types but caps static rules at 5,000 per extension and lacks onRuleMatchedDebug and getMatchedRules. Use browser.declarativeNetRequest (with the browser.* namespace) in Firefox; the API surface is otherwise identical for basic rule types. Safari 18+ added DNR support but uses the WebKit engine’s own quota enforcement, does not expose onRuleMatchedDebug, and requires the extension be packaged as a Safari Web Extension. Regex support in Firefox is more restrictive — test any regexFilter patterns explicitly in Gecko before shipping.

For the migration path from webRequest-based extensions, see migrating webRequest to declarativeNetRequest step by step.

Quota reference

ScopeLimit
Static rules (Chrome)30,000
Static rules (Firefox)5,000
Dynamic rules total30,000
Dynamic rules — unsafe (redirect/modifyHeaders)5,000
Session rules5,000
Regex rules across all scopes1,000
URL filter max length4,096 characters
Priority range1 – 2,147,483,647