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.

The four parts of a declarativeNetRequest ruleEvery rule is an id, a priority, a condition object and an action object; the engine matches on condition and resolves conflicts with priority.idunique integer within its rulesetused to remove the rule laterpriorityhigher integer wins a conflictdefaults to 1conditionurlFilter, resourceTypes, domainsall clauses must matchactionblock, allow, redirect, modifyHeadersallow beats block at equal priority
Priority only matters between rules that both matched — it never makes a non-matching condition match.
 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.

Applying a user's filter change as a dynamic rule swapThe options page sends the new filter, the worker removes the old rule id and adds the replacement in a single atomic call.Options pageService workerRule enginesendMessage({ type: 'SET_FILTER' })getDynamicRules()find the ids you owncurrent rule listupdateDynamicRules({ removeRuleIds, addRules })atomic swapack — rules live immediately
Removal and addition travel in one call so no request slips through between the two operations.
 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.

8. Designing a rule set that stays maintainable

A declarativeNetRequest rule set starts as a handful of hand-written rules and, in a successful extension, grows into thousands generated from lists, user preferences and per-tab overrides. The maintainability problems that follow are predictable, and a little structure at the start avoids them.

Separate rules by origin. Rules you ship belong in static rulesets; rules the user creates belong in dynamic rules; temporary, per-tab overrides belong in session rules. Mixing them — shipping product rules as dynamic rules, for example — means they can only be changed by code that runs on every install, and a mistake persists across updates. The trade-offs are laid out in dynamic vs static rulesets.

Allocate ids and priorities deliberately. Disjoint id ranges per source make it possible to see where a matched rule came from at a glance; a small table of priority tiers — shipped blocks, shipped exceptions, user blocks, user exceptions, temporary overrides — makes the resolution order predictable. Priority inflation in the shipped list is the most common reason a user’s own exception appears not to work.

Generate, count and assert. Large lists should be generated at build time with deterministic ids, counted against the static and regex budgets, and asserted in CI, so exceeding a limit fails the build instead of silently disabling a ruleset at install time — see staying under the rule count limits.

9. Explaining blocking to users

A request-filtering extension changes what pages do, and when a page breaks, users need to know whether the extension was responsible. The rule engine runs out of sight, so the extension has to surface what it did.

A per-tab badge count of blocked requests, a popup that lists the categories blocked on the current page, and a one-click pause for this site cover most needs. The pause is the important one: it gives users a way to test “is it the extension?” without disabling it everywhere, and it is implemented cleanly with a session rule scoped to the tab, as in scoping rules to a single tab. Reporting what was blocked should stay on the device — the URLs involved are the user’s browsing, and a count or category is all the UI needs.

10. Testing a rule set before users do

Rules are data, and data can be tested without a browser. The cheapest safety net is a table of example requests and the outcome each should have — blocked, allowed, redirected to a given URL — checked against the rule set on every change. Chrome’s testMatchOutcome evaluates a request against the live rules without issuing it, which makes such a table easy to run from an end-to-end test against the real extension.

1const cases = [
2  { url: "https://ads.example.com/tag.js", type: "script", expect: "block" },
3  { url: "https://example.com/app.js", type: "script", expect: "allow" },
4];
5for (const c of cases) {
6  const { matchedRules } = await chrome.declarativeNetRequest.testMatchOutcome({ url: c.url, type: c.type });
7  console.assert((matchedRules.length ? "block" : "allow") === c.expect, c.url);
8}

Execution context: the service worker of an unpacked build, driven from a test. Every bug report about a wrongly blocked page becomes a new row in the table, so the rule set accumulates exactly the regression coverage its real users have needed — the same approach used for debugging in debugging rules that don’t match.

Keep the table next to the rule files in the repository and review it alongside them. A pull request that changes a rule should usually add or change a row too; one that changes rules without touching the table is a signal to ask what behaviour the author expected, and how anyone would know if it changed again later. Over time the table becomes the most readable description of what the extension actually blocks.

MV3 constraints to design around

  • 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

Further guides in this topic

The guides below go deeper into specific declarative net request rules problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • 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.
  • Redirecting and Rewriting Headers with Rules — Use declarativeNetRequest redirect, transform and modifyHeaders actions in MV3 — regexSubstitution, extension-relative targets, and the host permissions each one demands.
  • Scoping Rules to a Single Tab — Apply declarativeNetRequest rules to one tab only using session rules and tabIds — per-tab pause, allowlists, and cleaning up rules when the tab closes.
  • 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.