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.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

“Pause blocking on this site” is a feature every content-filtering extension needs and the one declarativeNetRequest makes least obvious. Static and dynamic rules are global; there is no tabIds condition available to them. The only store that can express “this tab, right now” is session rules — and because those are cleared when the browser closes, the lifecycle of a per-tab rule is yours to manage. This guide is part of declarativeNetRequest rules.

Why only session rules can do this

A tab id is a runtime identity: it does not exist before the tab is opened and it means nothing after the browser restarts. Persisting a rule keyed to one would produce a rule that silently matches a different tab tomorrow. The browser enforces the distinction by rejecting tabIds and excludedTabIds on any rule that is not a session rule.

Pausing and resuming blocking for one tabThe user clicks pause in the popup; the worker adds a session allow rule scoped to the tab id, and removes it when the tab closes or the user resumes.PopupService workerRule engineTabsendMessage({pause, tabId})updateSessionRules(add allowAllRequests)tabs.reload(tabId)in-flight requests are unaffectedrequests re-evaluatedonRemoved(tabId)tab closedupdateSessionRules(remove)
The tab-removed listener is not optional — without it the rule outlives the tab and can shadow a future one.

Step-by-step

1. Derive a stable rule id from the tab id

Session rule ids must be unique, and you need to find the rule again later without scanning. Deriving it arithmetically makes both trivial.

1const PAUSE_BASE = 1_000_000;
2const pauseRuleId = (tabId) => PAUSE_BASE + tabId;

Execution context: a shared constants module. Tab ids are positive integers that do not collide within a browser session, so this mapping is injective for as long as the session rules live.

2. Add the allow rule

allowAllRequests on the main_frame exempts the document and everything it loads, which is what “pause on this site” should mean.

 1async function pauseTab(tabId) {
 2  await chrome.declarativeNetRequest.updateSessionRules({
 3    removeRuleIds: [pauseRuleId(tabId)],      // idempotent: clear before adding
 4    addRules: [{
 5      id: pauseRuleId(tabId),
 6      priority: 1000,                          // above every shipped rule
 7      action: { type: "allowAllRequests" },
 8      condition: { tabIds: [tabId], resourceTypes: ["main_frame"] },
 9    }],
10  });
11  await chrome.tabs.reload(tabId);
12}

Execution context: the service worker. Passing the same id in removeRuleIds and addRules makes the call safe to run twice — the browser applies removals before additions. allowAllRequests is only valid on main_frame and sub_frame resource types.

3. Resume, and clean up when the tab goes away

 1async function resumeTab(tabId) {
 2  await chrome.declarativeNetRequest.updateSessionRules({
 3    removeRuleIds: [pauseRuleId(tabId)],
 4    addRules: [],
 5  });
 6  await chrome.tabs.reload(tabId);
 7}
 8
 9chrome.tabs.onRemoved.addListener((tabId) => {
10  chrome.declarativeNetRequest.updateSessionRules({
11    removeRuleIds: [pauseRuleId(tabId)],
12    addRules: [],
13  });
14});

Execution context: the service worker, with onRemoved registered at the top level so it survives eviction. Removing a rule id that does not exist is not an error, so the listener needs no guard.

4. Reflect the state in the toolbar

The user needs to see that this tab is paused, and the badge is the cheapest place to say so.

 1async function paintBadge(tabId) {
 2  const rules = await chrome.declarativeNetRequest.getSessionRules();
 3  const paused = rules.some((r) => r.id === pauseRuleId(tabId));
 4  await chrome.action.setBadgeText({ tabId, text: paused ? "off" : "" });
 5  await chrome.action.setBadgeBackgroundColor({ tabId, color: "#b45309" });
 6}
 7
 8chrome.tabs.onActivated.addListener(({ tabId }) => paintBadge(tabId));
 9chrome.tabs.onUpdated.addListener((tabId, info) => {
10  if (info.status === "complete") paintBadge(tabId);
11});

Execution context: the service worker. Passing tabId to setBadgeText scopes the badge to that tab only — the per-tab action state described in enabling and disabling the toolbar action per tab.

5. Persist the user’s intent, not the rule

The rule is per-session; the user’s decision usually is not. Store the origin they paused and re-apply on navigation.

1chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
2  if (info.status !== "loading" || !tab.url) return;
3  const origin = new URL(tab.url).origin;
4  const { pausedOrigins = [] } = await chrome.storage.local.get("pausedOrigins");
5  if (pausedOrigins.includes(origin)) await pauseTab(tabId);
6});

Execution context: the service worker. This runs before most sub-resources are requested but is not a guarantee — for a hard requirement, keep the rule keyed to the origin as a dynamic rule and use the session rule only for a temporary override.

Per-tab, per-origin, or global?A decision tree mapping the scope the user asked for onto the rule store that can express it.What did the user actually ask to exempt?This tab, for nowSession ruletabIds conditionClean up on tabs.onRemovedids are reusedThis site, alwaysDynamic ruleinitiatorDomainsMirror to storage.syncsurvives reinstallEverything, for nowDisable the rulesetsupdateEnabledRulesetsRestore on a timeran alarm, not a timeout
Only the first branch needs session rules; the other two are cheaper and survive a restart.

Tab id reuse, and the stale rule it creates

Tab ids are integers assigned by the browser and reused after a tab closes. Within one session that reuse is not hypothetical: on a machine where the user opens and closes tabs all day, an id freed in the morning will be handed to a new tab in the afternoon.

A session rule keyed to a closed tab’s id therefore does not become inert — it becomes wrong. The next tab to receive that id silently inherits an allowAllRequests exemption its user never asked for, on a site they have never paused. Nothing logs it, and it is invisible in the extension’s own UI because the UI renders from the user’s stored intent, not from the live rule set.

That is the entire argument for the tabs.onRemoved listener in step 3, and it is worth one more defence in depth: a reconcile on startup that removes any tab-scoped rule whose tab no longer exists.

 1async function reconcileTabRules() {
 2  const [rules, tabs] = await Promise.all([
 3    chrome.declarativeNetRequest.getSessionRules(),
 4    chrome.tabs.query({}),
 5  ]);
 6  const live = new Set(tabs.map((t) => t.id));
 7  const stale = rules
 8    .filter((r) => r.condition.tabIds?.some((id) => !live.has(id)))
 9    .map((r) => r.id);
10  if (stale.length) {
11    await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: stale, addRules: [] });
12  }
13}

Execution context: the service worker, called from onStartup and from a low-frequency alarm. Session rules do not survive a browser restart, so the startup call is mostly a safety net for the case where a crash left the registry inconsistent; the alarm is what catches an onRemoved the worker missed because it was mid-eviction.

The same reasoning applies to any per-tab state you keep — badge text, injected UI, a cached scrape. Keying by tab id is convenient and correct only for as long as the tab exists; anything that should outlive it must be keyed by origin instead.

How a stale tab-scoped rule reaches the wrong tabA paused tab is closed while the worker is evicted, its id is reused by a new tab, and the leftover session rule exempts a site the user never paused.UserBrowserService workerSession rulescloses paused tab 42onRemoved(42)worker evicted, missedopens a new tabassigns id 42 againrule 1000042 still matchesreconcile on startup clears it
The window is small but it is not rare — and nothing in the UI shows the exemption.

Cross-browser variation

  • Chrome / Edge: tabIds and excludedTabIds are session-rule-only and are rejected with a clear validation error elsewhere. Tab ids are reused after a tab closes, which is why the cleanup listener matters.
  • Firefox: session rules arrived after the rest of the API and tab-scoped conditions have been less consistent across versions. Probe for declarativeNetRequest.updateSessionRules before offering the feature — the capability approach in building a capability matrix for your extension.
  • Safari: per-tab rules are effectively unavailable. The practical fallback is to disable the relevant static rulesets globally while the user has paused, and restore them afterwards.
  • All three: requests already in flight are not re-evaluated. A reload after changing the rule is what makes the change visible.

Verification

  1. Pause a tab, then confirm the rule exists and is scoped:
1(await chrome.declarativeNetRequest.getSessionRules())
2  .filter((r) => r.condition.tabIds?.length);
3// [{ id: 1000042, condition: { tabIds: [42], … }, action: { type: "allowAllRequests" } }]

Execution context: the service worker console. The id should be 1000000 + tabId, which makes a stale rule obvious at a glance.

  1. Load the same site in a second tab and confirm it is still filtered — that is the whole point of the scoping.
  2. Close the paused tab, then re-run the query and confirm the rule is gone.

FAQ

Can I scope a rule to a window instead of a tab?

Not directly. Enumerate the window’s tabs with chrome.tabs.query({ windowId }) and add one session rule per tab, removing them on onRemoved. There is no window-level condition.

Why is the page still blocked after I add the rule?

Because the document request already completed. allowAllRequests applies from the next navigation onward, so reload the tab as part of the pause action.

Do session rules survive a service worker eviction?

Yes. They live in the browser, not in the worker, and are cleared only when the browser session ends. The worker being evicted between adding and removing one is exactly why the onRemoved listener must be registered at the top level.

Other Core APIs & Cross-Browser Data Management Resources