DEV Community

Cover image for Keeping declarativeNetRequest rules in sync after a Manifest V3 service worker restarts
Parmarth
Parmarth

Posted on AI-assisted

Keeping declarativeNetRequest rules in sync after a Manifest V3 service worker restarts

By mid-afternoon, my Chrome usually had 40+ tabs open across three windows. I tried several tab managers, but they either demanded a cloud account, locked basic features behind a subscription, or only solved one half of the problem.

So I spent the last few months building my own: PYLON, an offline-first New Tab that pairs project tab workspaces with dev tools and a deep-focus Pomodoro timer.

This post is about one specific engineering challenge: the site blocker.

The hardest part wasn’t intercepting requests — it was deciding what the source of truth should be when a Manifest V3 background service worker constantly terminates and restarts.


The setup

When a focus session starts, the extension redirects configured distraction sites to a local focus-intercept.html page bundled inside the extension.

I chose Chrome's modern declarativeNetRequest (DNR) API over old blocking webRequests so that the browser itself evaluates rules at native speed without injecting custom JavaScript into the request path.

Three pieces of state have to stay consistent:

What Where it lives Why
Blocked domains chrome.storage.local Survives restarts, handles crashes, easily exported
Active redirect rules DNR Dynamic Rules Evaluated directly by the browser network stack
Rule IDs A reserved ID range (1000–1999) Ensures we only delete focus rules without touching other extension rules

My core rule: chrome.storage.local is the sole source of truth. DNR rules are merely a temporary mirror of it.

Flowchart showing how declarativeNetRequest rules synchronize with local storage across four steps


Step 1: Clean and normalize user input

Users paste whatever happens to be in their address bar:

  • https://www.youtube.com/watch?v=...
  • reddit.com/r/webdev
  • x.com:443

Because DNR rules match against hostname patterns (typically using ||domain.com^), passing raw URLs will break rule matching. Every entry must be cleaned to a bare domain before touching storage:

const cleaned = Array.from(new Set((domains || [])
  .map(d => String(d || '').trim().toLowerCase()
    .replace(/^https?:\/\//, '')   // drop the protocol
    .replace(/\/.*$/, '')          // drop any trailing path
    .split(':')[0].split('?')[0])  // drop port numbers and query parameters
  .filter(Boolean)));
Enter fullscreen mode Exit fullscreen mode

Step 2: Rebuild the rules — don't patch them

My initial instinct was incremental updates: add a rule when a user adds a domain, remove one when deleted.

In Manifest V3, incremental patching is an anti-pattern.

Service workers can terminate at any point of idle time. If an event wakes the worker while a write is resolving, or if two storage events trigger simultaneously, incremental updates can leave stale, orphaned rule IDs in the browser engine.

Instead, I adopted an idempotent reconciliation pattern:

  1. Read existing dynamic rules.
  2. Filter for rule IDs belonging to my reserved range.
  3. In a single atomic updateDynamicRules() call, delete all stale IDs and re-apply fresh rules generated from storage.
  4. Retry with backoff if Chrome's internal database locks.
async function reconcileDnrWithStorage(expectedDomains, retryCount = 0) {
  try {
    const existing = await chrome.declarativeNetRequest.getDynamicRules();
    const staleIds = existing
      .map(r => r.id)
      .filter(id => id >= FOCUS_RULE_ID_START && id <= FOCUS_RULE_ID_END);

    await chrome.declarativeNetRequest.updateDynamicRules({
      removeRuleIds: staleIds,
      addRules: buildShieldRules(expectedDomains || [])
    });
  } catch (e) {
    if (retryCount < 3) {
      await new Promise(r => setTimeout(r, 500));
      await reconcileDnrWithStorage(expectedDomains, retryCount + 1);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If the focus session ends, passing an empty array automatically clears all rules in that range.


Step 3: Sweeping already-open tabs

One gotcha with declarativeNetRequest: redirect rules only apply to new network requests.

If a user already has YouTube open in a tab and starts a focus sprint, that tab remains accessible until they click a link or refresh.

To make the block instant, the activation sequence performs a quick tab sweep right after reconciling DNR rules:

const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
  if (tab.url && isDomainBlocked(tab.url, expectedDomains)) {
    chrome.tabs.update(tab.id, { 
      url: chrome.runtime.getURL('focus-intercept.html') 
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Questions for other extension developers:

Building on Manifest V3 has been an interesting exercise in distributed-systems thinking inside a single browser:

  1. Retry Strategy: Is a static 500ms delay sufficient for DNR rule updates, or have you found exponential backoff necessary under heavy browser load?
  2. Reconciliation Frequency: Do you reconcile DNR rules on every service worker startup (chrome.runtime.onStartup), or strictly when storage change listeners fire?
  3. Race Condition Testing: Has anyone established a reliable local testing setup for simulating abrupt service worker termination during active DNR writes?

If you've tackled DNR sync differently, I’d love to hear your approach in the comments.


Try it out:

If you're looking for an offline-first tab manager with native Chrome Tab Groups sync, developer tools (CORS toggle, localhost port monitor), and this focus shield:

👉 PYLON on the Chrome Web Store (Free, 100% offline, zero accounts/tracking)

Top comments (1)

Collapse
 
launchgatecheck profile image
Launch Gate •

I'd test a stale retry before tuning the backoff: start focus, force that reconciliation to fail once, then end focus while its 500ms retry is waiting. The retry still carries the old expectedDomains, so can it reinstall blocking rules after the newer empty-array update? An atomic DNR call doesn't by itself order separate reconciliations. Re-reading current state or rejecting an obsolete generation would make that fixture useful. Also, after the final retry fails, the function currently returns without surfacing failure; does activation still sweep tabs and show the session as protected?