Building a Content Blocker with declarativeNetRequest

Build an MV3 ad and tracker blocker with declarativeNetRequest: convert filter lists to static rulesets, stay within rule limits, toggle rulesets, per-site pausing with dynamic allow rules, and cosmetic filtering.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

Content blockers were the extensions most affected by Manifest V3: the blocking webRequest listener that evaluated every request in JavaScript is gone, replaced by rules the browser evaluates itself. A capable blocker is still entirely possible — the largest blockers ship MV3 versions — but its architecture is different. Filter lists become static rulesets compiled at build time, user customisation becomes dynamic rules, per-site pausing becomes high-priority allow rules, and element hiding moves to content scripts. This guide builds that architecture end to end. It belongs to declarativeNetRequest rules.

How a DNR blocker is put together

A filter list like EasyList contains two kinds of rules. Network filters — “block requests to ||ads.example.com^” — map onto DNR rules with a block action and a requestDomains or urlFilter condition. Cosmetic filters — “hide .ad-banner on news.site” — have no DNR equivalent and are applied by injecting CSS from a content script. At build time, a converter turns the network filters into JSON rulesets declared in the manifest; the browser compiles and indexes them at install. At runtime, the extension’s job shrinks to switching rulesets on and off, adding the user’s own rules and allow-list as dynamic rules, and showing what was blocked. No JavaScript runs per request, which is the point: blocking is faster and the worker sleeps.

Layers of an MV3 content blockerStatic rulesets compiled from filter lists, dynamic rules for user filters, high-priority dynamic allow rules for paused sites, session rules for temporary toggles, and content-script CSS for cosmetic filtering.Session rulestemporary, per browser sessionpause for this tabDynamic allow rulespriority 100, allowAllRequestspaused sitesDynamic rulesuser's custom filters30,000 limitStatic rulesetsEasyList, privacy listcompiled at buildCosmetic CSScontent scriptelement hiding
The browser enforces the top four layers without waking your worker.

Step-by-step: from filter list to working blocker

1. Convert filter lists to rulesets at build time

1# Using a filter-list-to-DNR converter in the build
2npx abp2dnr < lists/easylist.txt > rulesets/easylist.json
3npx abp2dnr < lists/easyprivacy.txt > rulesets/easyprivacy.json
4node scripts/split-cosmetic.mjs lists/easylist.txt > cosmetic/easylist.json

Execution context: your build machine. Converters translate network filters into DNR rule objects and report filters they cannot express (some option combinations, certain regexes). The cosmetic filters are extracted separately for the content script. Run conversion in CI so updated lists produce a new release; MV3 does not allow downloading new rules as code, but dynamic rules can carry small, frequent updates (step 4).

2. Declare the rulesets in the manifest

 1{
 2  "permissions": ["declarativeNetRequest"],
 3  "host_permissions": ["<all_urls>"],                    // needed for cosmetic filtering and redirects
 4  "declarative_net_request": {
 5    "rule_resources": [
 6      { "id": "easylist",    "enabled": true,  "path": "rulesets/easylist.json" },
 7      { "id": "easyprivacy", "enabled": true,  "path": "rulesets/easyprivacy.json" },
 8      { "id": "annoyances",  "enabled": false, "path": "rulesets/annoyances.json" }
 9    ]
10  }
11}

Execution context: the manifest. declarativeNetRequest blocks without host permissions — the “block content on any page” warning comes from the permission itself — but cosmetic filtering needs content scripts on every site, hence the broad host. Disabled rulesets ship in the package and can be enabled at runtime without an update. Chrome guarantees each extension 30,000 enabled static rules and lets extensions share a larger global pool beyond that; check chrome.declarativeNetRequest.getAvailableStaticRuleCount() before enabling more.

Build-time compilation and runtime controlFilter lists are converted at build time into static JSON rulesets and cosmetic selector files; at runtime the worker only toggles rulesets, manages dynamic rules and the allow-list, while the browser matches requests.Filter listsEasyList, privacyConverternetwork → DNR JSONStatic rulesetsin the packageinstalled; browser indexes rulesBrowser matcherevery requestWorkertoggles + dynamic rulesContent scriptcosmetic CSS
The heavy lifting happens at build time and in the browser's own matcher.

3. Let users toggle lists

1// sw.js
2export async function setListEnabled(id, on) {
3  if (on) {
4    const available = await chrome.declarativeNetRequest.getAvailableStaticRuleCount();
5    const size = RULESET_SIZES[id];                        // recorded at build time
6    if (size > available) throw new Error(`Not enough rule capacity for ${id} (${size} > ${available})`);
7  }
8  await chrome.declarativeNetRequest.updateEnabledRulesets(on ? { enableRulesetIds: [id] } : { disableRulesetIds: [id] });
9}

Execution context: the service worker, called from the options page. Enabled state persists across restarts but resets to the manifest defaults on extension update in some versions, so store the user’s choices and re-apply them in onInstalled. Recording each ruleset’s rule count at build time lets you warn the user before enabling a list that would not fit.

4. Add user filters as dynamic rules

 1export async function addUserBlock(domain) {
 2  const { nextRuleId = 1_000_000 } = await chrome.storage.local.get("nextRuleId");
 3  await chrome.declarativeNetRequest.updateDynamicRules({
 4    addRules: [{
 5      id: nextRuleId, priority: 2,
 6      action: { type: "block" },
 7      condition: { requestDomains: [domain], resourceTypes: ["script", "image", "xmlhttprequest", "sub_frame", "media"] },
 8    }],
 9  });
10  await chrome.storage.local.set({ nextRuleId: nextRuleId + 1 });
11}

Execution context: the service worker. Dynamic rules persist across restarts and updates, which makes them right for the user’s own filters. Keep rule ids unique and track them; reusing an id replaces nothing and fails the update. Avoid main_frame in user block rules unless the user explicitly asked to block whole sites — blocking navigation surprises people. Rule-count limits and id management are covered in staying under the rule count limits.

5. Pause blocking on a site

 1export async function pauseOn(hostname) {
 2  const id = await idFor(`pause:${hostname}`);
 3  await chrome.declarativeNetRequest.updateDynamicRules({
 4    removeRuleIds: [id],
 5    addRules: [{
 6      id, priority: 100,
 7      action: { type: "allowAllRequests" },
 8      condition: { requestDomains: [hostname], resourceTypes: ["main_frame"] },
 9    }],
10  });
11}

Execution context: the service worker. allowAllRequests on the main_frame allows every request made by pages on that site, including third-party ones, as long as the rule’s priority beats the block rules. A high priority guarantees it wins. This is how “pause on this site” buttons work. For “pause on this tab only”, use session rules with tabIds, as in scoping rules to a single tab.

Rule types for each blocker featureWhich DNR rule layer, action and priority implement filter lists, user filters, site pausing, tab pausing and element hiding.FeatureLayerActionPriorityFilter listsStaticblock1User filtersDynamicblock2Pause siteDynamicallowAllRequests100Pause tabSessionallowAllRequests + tabIds100Hide elementsContent scriptCSS display:none—
Each feature maps to a different layer — mixing them up causes most blocker bugs.

6. Apply cosmetic filters from a content script

1// cosmetic.js — content script at document_start
2const { cosmetic } = await chrome.storage.local.get("cosmetic");     // selectors keyed by hostname
3const host = location.hostname;
4const selectors = [...(cosmetic?.generic ?? []), ...(cosmetic?.[host] ?? [])];
5if (selectors.length && !(await isPaused(host))) {
6  const style = document.createElement("style");
7  style.textContent = `${selectors.join(",\n")} { display: none !important; }`;
8  (document.head ?? document.documentElement).append(style);
9}

Execution context: a content script injected at document_start on all sites. Element hiding must be fast to avoid a flash of ads; reading pre-split selectors from storage is quick. For very large generic selector lists, chrome.scripting.insertCSS from the worker on webNavigation.onCommitted is an alternative that avoids the content script parsing them. Respect the same pause state as the network rules.

7. Ship list updates through releases and dynamic deltas

Static rulesets change only with an extension update. For urgent fixes between releases — a filter that breaks a popular site — ship a small data file of rule ids to disable, fetched and validated by the worker, and apply it with updateStaticRules({ rulesetId, disableRuleIds }) (Chrome 111+). That is configuration, not code, and stays within store policy.

Common mistakes

  • Converting lists at runtime. Static rules must ship in the package; runtime conversion into dynamic rules hits the dynamic limit fast.
  • Pausing with low-priority allow rules. Block rules with equal or higher priority still win.
  • Blocking main_frame from user filters. Users meant “block this tracker”, not “block this site”.
  • Ignoring rule capacity. Enabling a large optional list can fail when the global pool is exhausted.
  • No cosmetic layer. Network blocking alone leaves empty ad slots and banners.

Cross-browser variation

  • Chrome / Edge: full DNR including getAvailableStaticRuleCount, updateStaticRules and a shared static-rule pool.
  • Firefox: supports DNR with static and dynamic rules; limits and some conditions differ. Firefox also keeps blocking webRequest, which some blockers use for capabilities DNR lacks.
  • Safari: supports DNR in recent versions with its own limits and some unsupported conditions; Safari also has native content blockers through the containing app, which some extensions use instead.

Verification

  1. Load a test page that requests a known ad domain and confirm the request shows as blocked in DevTools’ Network panel.
  2. Pause the site and reload: the request succeeds.
  3. Disable a ruleset from options and confirm the corresponding requests succeed.
  4. Check that ads’ empty containers are hidden by cosmetic CSS and reappear when paused.

FAQ

Can I count blocked requests per tab?

Yes, with setExtensionActionOptions({ displayActionCountAsBadgeText: true }) or getMatchedRules. See showing blocked request counts on the badge.

Do static rules cost memory when disabled?

Disabled rulesets are not indexed and do not count against the enabled limit, but they still ship in the package.

How do I debug why a request was or was not blocked?

Use the unpacked extension’s DNR feedback events, covered in debugging rules that don’t match.

Other Core APIs & Cross-Browser Data Management Resources