If your extension used chrome.webRequest.onBeforeRequest or onHeadersReceived with ['blocking'] in the extraInfoSpec, it now silently does nothing in Manifest V3. The listener registers without error, but requests pass through unmodified. This guide walks through every step needed to replace that pattern with Declarative Net Request rules that work correctly in MV3.
Root cause: why blocking webRequest no longer works
MV3 removes the 'blocking' extraInfoSpec option entirely. The design goal was to shift network filtering off the main thread — synchronous JavaScript callbacks that cancel or modify requests in flight force the browser to stall the renderer and wait for extension code to complete. That latency is unacceptable in a model where extensions run as service workers that can be suspended at any time.
Service workers cannot intercept requests synchronously. They have no persistent event loop to hook into, and there is no mechanism to block a network fetch while an async function resolves. The browser instead evaluates filtering rules in a dedicated engine before any JavaScript runs. All filtering logic must therefore be expressed as declarative JSON rules registered ahead of time — not computed at request time.
Step 1: Audit existing webRequest listeners
Search your codebase for every call to chrome.webRequest.onBeforeRequest.addListener, onHeadersReceived.addListener, and onBeforeSendHeaders.addListener. For each one, record the URL match patterns, types array, and what the callback does — cancel the request, redirect it, or mutate headers.
A typical MV2 blocking listener looks like this:
1// MV2 background.js — this pattern is invalid in MV3
2chrome.webRequest.onBeforeRequest.addListener(
3 (details) => {
4 return { cancel: true };
5 },
6 { urls: ['*://*.tracker.example.com/*'], types: ['script', 'xmlhttprequest'] },
7 ['blocking']
8);
Execution context: This ran in a persistent MV2 background page. In an MV3 service worker the listener can still be registered, but the ['blocking'] spec is ignored and the return value is discarded — requests are never cancelled.
For each listener you find, create a mapping row:
| Listener | URL pattern | Resource types | Action |
|---|
onBeforeRequest | *://*.tracker.example.com/* | script, xmlhttprequest | block |
onHeadersReceived | *://api.example.com/* | xmlhttprequest | modifyHeaders |
onBeforeRequest | *://old.example.com/* | main_frame | redirect |
Also check your manifest.json for "webRequest" and "webRequestBlocking" permissions — both can be removed once migration is complete.
Step 2: Map blocking logic to DNR rule objects
Each row in your audit becomes a declarativeNetRequest.Rule object. The structure has four required fields: id, priority, condition, and action.
1// MV3 — equivalent block rule for the first audit row
2const blockRule = {
3 id: 1,
4 priority: 1,
5 action: { type: 'block' },
6 condition: {
7 urlFilter: '||tracker.example.com^',
8 resourceTypes: ['script', 'xmlhttprequest']
9 }
10};
Execution context: Rule objects are plain JSON. They are validated at registration time, not at request time. An invalid urlFilter pattern causes the entire updateDynamicRules call to reject.
The urlFilter syntax is its own mini-language:
|| at the start anchors to the domain boundary — ||example.com matches https://example.com/path and https://sub.example.com/path^ matches a separator character (slash, query mark, or end of URL) — it is the idiomatic wildcard for “anything after the domain”* matches any sequence of characters anywhere in the pattern
So ||tracker.example.com^ matches every URL on that domain regardless of path or protocol. For more surgical matching, ||api.example.com/v2/track^ would restrict to a specific path prefix.
The resourceTypes array filters by request type. Valid values include main_frame, sub_frame, stylesheet, script, image, font, object, xmlhttprequest, ping, csp_report, media, websocket, and other. Omitting resourceTypes from the condition matches all types.
When multiple rules match the same request, the rule with the highest priority value wins. Assign priorities deliberately — a catch-all block rule should have a lower priority than a narrower allow rule so exceptions work correctly.
Step 3: Register static rules in the manifest
Rules you know at build time belong in static rule files declared in manifest.json. Static rules are loaded when the extension installs or updates and count against a shared global limit.
1// manifest.json (MV3)
2{
3 "manifest_version": 3,
4 "permissions": ["declarativeNetRequest"],
5 "host_permissions": ["*://*.tracker.example.com/*"],
6 "declarative_net_request": {
7 "rule_resources": [
8 {
9 "id": "block_rules",
10 "enabled": true,
11 "path": "rules/block_rules.json"
12 }
13 ]
14 }
15}
Execution context: Static rule files are loaded by the browser at install time. They do not require any service worker code to activate — the browser engine evaluates them directly.
The referenced rules/block_rules.json is a plain JSON array:
1[
2 {
3 "id": 1,
4 "priority": 1,
5 "action": { "type": "block" },
6 "condition": {
7 "urlFilter": "||tracker.example.com^",
8 "resourceTypes": ["script", "xmlhttprequest"]
9 }
10 }
11]
Execution context: This file is read by the browser, not by your extension’s JavaScript. It must be valid JSON with no trailing commas. Use a JSON linter as part of your build step.
Step 4: Register dynamic rules for user-configurable filters
Filters that users can configure at runtime cannot live in a static file — they must be registered programmatically using chrome.declarativeNetRequest.updateDynamicRules(). Dynamic rules survive browser restarts and are tied to the extension installation.
1// service-worker.js — add user-defined block rules at runtime
2async function applyUserFilters(blockedDomains) {
3 // Fetch existing dynamic rules to build a removal list
4 const existing = await chrome.declarativeNetRequest.getDynamicRules();
5 const removeRuleIds = existing.map((r) => r.id);
6
7 const addRules = blockedDomains.map((domain, index) => ({
8 id: index + 100, // offset to avoid collisions with static rule IDs
9 priority: 1,
10 action: { type: 'block' },
11 condition: {
12 urlFilter: `||${domain}^`,
13 resourceTypes: ['script', 'image', 'xmlhttprequest', 'ping']
14 }
15 }));
16
17 await chrome.declarativeNetRequest.updateDynamicRules({
18 removeRuleIds,
19 addRules
20 });
21}
Execution context: Call this from your service worker, typically in response to a message from the options page or popup after the user saves new settings. The call is async — await it before confirming success to the UI.
Dynamic rule limits as of Chrome 121:
| Rule type | Limit |
|---|
| Dynamic rules total | 30,000 |
| Dynamic “unsafe” rules (redirect, modifyHeaders) | 5,000 |
Session rules (updateSessionRules) | 5,000 |
| Regex rules across all scopes | 1,000 |
Session rules work the same way as dynamic rules but are cleared when the browser restarts. Use chrome.declarativeNetRequest.updateSessionRules() for temporary filters — for example, blocking requests during a specific user workflow without persisting that block across restarts.
Step 5: Handle redirect and modifyHeaders actions
Not all blocking webRequest logic was simple cancellation. Redirect rules and header mutation rules require slightly different action structures.
For redirects:
1// Redirect rule — sends old.example.com to new.example.com
2const redirectRule = {
3 id: 50,
4 priority: 2,
5 action: {
6 type: 'redirect',
7 redirect: { regexSubstitution: 'https://new.example.com\\1' }
8 },
9 condition: {
10 regexFilter: 'https://old\\.example\\.com(.*)',
11 resourceTypes: ['main_frame']
12 }
13};
Execution context: Redirect rules are classified as “unsafe” rules and count toward the 5,000 unsafe rule limit within the 30,000 dynamic rule ceiling.
For header modification — the MV3 replacement for onHeadersReceived with ['blocking']:
1// modifyHeaders rule — removes a tracking header on outbound requests
2const headerRule = {
3 id: 60,
4 priority: 1,
5 action: {
6 type: 'modifyHeaders',
7 requestHeaders: [
8 { header: 'X-Client-Data', operation: 'remove' }
9 ],
10 responseHeaders: [
11 { header: 'X-Frame-Options', operation: 'set', value: 'DENY' }
12 ]
13 },
14 condition: {
15 urlFilter: '||api.example.com^',
16 resourceTypes: ['xmlhttprequest']
17 }
18};
Execution context: modifyHeaders rules also count as unsafe rules. Valid operation values are set, append, and remove. Header names are case-insensitive. Some headers (like Cookie and Authorization) cannot be modified by extensions regardless of the rule.
Cross-browser variation
Chrome: DNR is available from Chrome 84. Dynamic rules (updateDynamicRules) require Chrome 91+. The 30,000 dynamic rule limit requires Chrome 121+ — earlier versions cap dynamic rules at 5,000. onRuleMatchedDebug is available and requires the declarativeNetRequestFeedback permission plus developer mode. regexFilter with regexSubstitution is well-supported.
Firefox: DNR support landed in Firefox 113. Static rule files are capped at 5,000 rules per rule resource (not 30,000). Firefox does not implement onRuleMatchedDebug — use the Network tab in the browser toolbox instead. Use the browser.* namespace; Firefox does not alias it to chrome.* for DNR calls in all contexts. urlFilter glob patterns work reliably; regexFilter support is more limited — test regex rules explicitly in Firefox before shipping.
Safari: DNR is supported in Safari 15.4+ on macOS and iOS. The extension manifest requires NSExtensionPointIdentifier set to com.apple.Safari.web-extension in the Info.plist. Safari does not support onRuleMatchedDebug. The browser.* namespace is required.
A cross-browser safe approach is to write all conditions using urlFilter glob syntax rather than regexFilter, since glob support is more consistent across engines.
Verification
Confirm rules are registered by calling getDynamicRules() immediately after updateDynamicRules() resolves:
1// Verify rules are active after registration
2const registered = await chrome.declarativeNetRequest.getDynamicRules();
3console.log('Active dynamic rules:', registered.length);
4console.table(registered.map((r) => ({ id: r.id, urlFilter: r.condition.urlFilter })));
Execution context: Run this in the service worker or paste it into the service worker’s DevTools console (chrome://extensions → Inspect service worker). It returns the live rule set, not a cached snapshot.
For per-request confirmation in Chrome, add a debug listener while developing:
1// Chrome only — requires declarativeNetRequestFeedback permission + developer mode
2chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => {
3 console.log(`Rule ${info.rule.ruleId} matched ${info.request.url}`);
4});
Execution context: This listener only fires in developer mode. Remove it (or guard it behind a flag) before publishing. For production monitoring, use the DevTools Network tab — blocked requests show a red strikethrough, and redirects show a 307 status with the new URL.
For static rules, check chrome://extensions → your extension → Inspect views → background to verify no rule file parse errors appear on load.
If rules match less than expected, see Debugging rules that don’t match for a systematic diagnostic process covering priority conflicts, missing host permissions, and urlFilter syntax errors.
FAQ
My block rules register without errors but requests still go through. What am I missing?
The most common cause is missing host_permissions. DNR silently ignores rules for domains not covered by the extension’s host permissions. Add the target domain to host_permissions in manifest.json and reload the extension. The second most common cause is a resourceTypes mismatch — if the rule specifies ['script'] but the request is typed as xmlhttprequest, the rule does not apply.
Can I read details.requestBody in DNR rules the way I could in webRequest?
No. DNR rules are evaluated before the request body is available and operate entirely on URL patterns, headers, and resource type metadata. If your MV2 extension inspected requestBody to make blocking decisions, that logic cannot be replicated in DNR. You will need to redesign the feature — for example, by moving filtering upstream to a server component or restructuring how the request carries the relevant information.
What is the difference between dynamic rules and session rules?
Dynamic rules registered with updateDynamicRules() persist across browser restarts until the extension explicitly removes them. Session rules registered with updateSessionRules() are automatically cleared when the browser closes. Use session rules for temporary filters scoped to a single browsing session — they have a separate 5,000-rule limit and do not consume your dynamic rule quota.
My extension needs more than 30,000 dynamic rules. What are the options?
The 30,000 dynamic rule cap is enforced by the browser and cannot be raised. Strategies to work within it: merge overlapping urlFilter patterns into broader rules where the precision loss is acceptable; use static rule files for the stable portion of your filter list (these have a separate 30,000 global limit shared across all enabled static rule sets); or apply server-side filtering for very large blocklists and push only user-specific overrides into dynamic rules.