Safari has no blocking webRequest. Here's what to do instead.

At ZeroClick I owned the Safari and iOS builds of Pie Adblock, an ad blocker with more than two million users. The Chrome build decided what to block in JavaScript, in the request path, through the blocking webRequest API. Blocking is the whole product. Porting to Safari meant moving that entire layer to a browser that refuses to run it.
The failures we hit were quiet. Rules installed without complaint while ads kept loading, with nothing in the console to explain it. Redirect rules that behaved in testing broke Spotify completely in production. For an ad blocker both outcomes are fatal. One ships a product that does nothing, and the other breaks sites users care about.
You may be at the same point. You ran Apple’s converter, the extension loads in Safari, and the request blocking does nothing. No errors show up. The rules installed, getDynamicRulesreturns them, and ads load anyway. Or worse, a site that worked in Chrome breaks outright. You didn’t break the port. This is Safari’s design.
This wall is part of a larger shift in extension engineering. Browsers are pulling extension code out of the request path and replacing it with rules you declare ahead of time, which the browser enforces itself. Chrome pushed that model with Manifest V3. Safari got there earlier and enforces it harder, with lower rule limits and failures that never reach the console.
Three approaches came out of our migration. Blocking itself moved to declarativeNetRequest, where you register rules ahead of time and the browser applies them, and that move won. Our webRequest listeners stayed only where they observe without blocking. Redirect rules got cut after Spotify. The sections below cover what ports cleanly, what needs a redesign, and the pitfalls in each losing approach. Everything here is what shipped.
The model change
Start with the first thing to verify, because it explains most silent failures. A webRequest listener registered with the blocking option never intercepts anything in Safari. The request completes no matter what your listener returns.
In Chrome, historically, your JavaScript could sit in the request path: see each request, decide, then block or rewrite it. Safari never supported that. What Safari 15 and later does support is observation, meaning non-blocking webRequest events that tell you a request happened. Extension code in Safari can watch requests, but it can never block or modify one in flight.
Observe-only uses can stay. Guard them so the same background script runs on a build where the API is absent:
// Safari has never run blocking webRequest: the 'blocking' option
// is unavailable, and the request completes regardless of what a
// listener returns. Since Safari 15 the events do fire as
// notifications, so observe-only uses still work.
//
// Our Safari build omits the webRequest permission entirely, so
// the API object is absent there. Optional chaining turns this
// whole registration into a no-op on that build.
browser.webRequest?.onHeadersReceived.addListener(
(details) => {
// Fine anywhere: note that the page produced a response.
markPageLoaded(details.tabId);
// No return value from here can block the request in Safari.
},
{ urls: ['<all_urls>'], types: ['main_frame'] },
);So how do you block anything? You declare it. Safari implements declarativeNetRequest, DNR for short: you register rules ahead of time, built from match patterns, resource types, and actions like block or header modification, and the browser applies them itself. Your code never hears about individual requests.
Chrome moved the same direction with Manifest V3, so if you already built DNR rules for Chrome, a meaningful part of the Safari migration is done. The differences that remain are rule limits, supported action types, and behavior that shifts with Safari versions. The rest of this article maps where those differences bite.
What survives the migration cleanly
A good share of a typical blocking layer ports without redesign. Three categories carry over, one of them with a version floor worth writing down.
- Static blocklists survive. Domain and URL-pattern blocking translates directly into DNR rules.
- Header changes survive, with a version floor. Removing or setting specific headers is expressible as
modifyHeadersrules on Safari 15.4 and later, and only with thedeclarativeNetRequestWithHostAccesspermission in the manifest. Below that floor, ported header code stops rewriting and nothing tells you. - User-toggled rule sets survive. Static rulesets, the named rule files you declare in the manifest and ship with the extension, flip on and off at runtime with
updateEnabledRulesetsas settings change. Pie’s cookie-popup blocking and allowlists, the sites a user exempts from blocking, worked this way, with a periodic reconciliation pass keeping browser state in line with settings.
Here’s what a header rewrite looks like on the other side of the migration:
// The MV2 version of this ran in onBeforeSendHeaders and decided
// per request. The Safari version is a declaration the browser
// applies on its own. It needs Safari 15.4 or later plus the
// declarativeNetRequestWithHostAccess permission in the manifest.
// Miss either one and the rule is ignored with no error.
await browser.declarativeNetRequest.updateDynamicRules({
addRules: [
{
id: SET_REFERER_RULE_ID,
priority: 1,
action: {
type: 'modifyHeaders',
requestHeaders: [
{ header: 'Referer', operation: 'set', value: 'https://partner.example/' },
],
},
condition: {
urlFilter: '||api.partner.example^',
resourceTypes: ['xmlhttprequest'],
},
},
],
});Notice what’s missing from the list: redirects. They get their own section.
When a redirect rule takes down a whole site
A redirect rule that behaves in Chrome can break an entire site in Safari. Safari’s documentation lists the redirect action as supported, and a simple from-pattern-to-URL rule may pass your tests. Production is where the trouble shows up.
On Pie, with our redirect rules loaded, Spotify broke completely. We never found the root cause. The fix that shipped drops every redirect rule from the Safari build, giving up redirect-based blocking there in exchange for sites that work. Verify redirect rules on the real sites your users visit before you ship them to Safari, and be ready to cut them.
What needs a redesign
Anything that depended on your code hearing about individual requests needs a new home. Four categories cover most of the redesign work.
- Logic that inspects request or response content. DNR matches on URL, resource type, and header-level patterns, and it will never show your code the payload. Whatever decision you were making from content has to move into a content script, into heuristics you can express as rules, or out of the product.
- Rules computed per request at runtime. You can update dynamic rules, the ones your code installs at runtime instead of shipping in the manifest, as often as you like, just never in the request path. New rules also don’t affect requests already in flight on the current page. The pattern that works is recompile and reload: regenerate the rule set when state changes, diff it against what’s installed, then reload the page so the new rules meet a fresh request stream.
- Counting and telemetry from the request path. Nothing fires when a rule matches, so a “blocked 47 ads on this page” counter reads zero while blocking works fine. The browser’s match feedback can’t carry it in production:
getMatchedRulesis quota-limited, meaning the browser caps how often you can call it, andonRuleMatchedDebugonly works in Chrome with the extension loaded unpacked from a local folder. Pie’s shipped counters came entirely from DOM-level signals, injected scripts counting the ad placements they found and hid, while the browser’s match data ran only in a development-only monitor that never shipped in production builds. - Massive filter lists. These are the subscribable rule lists an ad blocker compiles its blocking rules from, and each browser caps how many rules it will load. Chrome’s Manifest V3 allows up to 330,000 enabled static rules; Safari’s ceiling is smaller and moves by version, raised from 50,000 to 150,000 in Safari 15. Pie’s Chrome build shipped a list of roughly 195,000 rules, while the Safari build shipped a pruned list of about 43,000 plus a second, mobile-optimized list. A big list needs a per-browser pruning strategy, because over the limit the list fails to load or coverage quietly degrades.
The broken rules already on user devices
One more trap, and it’s the mechanism behind the silent failure this article opened with. Safari accepts Chrome-shaped rules into its dynamic rule store without an error, and getDynamicRules returns them afterward. They just never match, or they match destructively and break the site. They also persist on user devices across extension updates.
Fixing your rule converter repairs future installs only. The broken rules already sitting on user devices stay there until you ship a repair pass that rewrites them. Here’s the shape of the one we shipped:
// Safari accepted these rules without complaint, so the invalid
// ones are sitting in the dynamic store on real devices, doing
// nothing or breaking sites. They survive extension updates.
// This runs once at startup on the Safari build and rewrites them.
async function repairInstalledRules() {
const rules = await browser.declarativeNetRequest.getDynamicRules();
const removeRuleIds = [];
const addRules = [];
for (const rule of rules) {
if (rule.action.type === 'allowAllRequests') {
// Safari refuses the domain-filtered form we relied on. Drop it.
removeRuleIds.push(rule.id);
} else if (rule.condition.initiatorDomains) {
// Rewrite to the older 'domains' key Safari reads.
const { initiatorDomains, ...rest } = rule.condition;
removeRuleIds.push(rule.id);
addRules.push({ ...rule, condition: { ...rest, domains: initiatorDomains } });
}
}
if (removeRuleIds.length === 0) return;
await browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds, addRules });
}There’s no error surface here, so the only way we could tell the repair worked at scale was analytics. Every branch in the pass reported an identifier we could count. Budget for that. You’re debugging a system that will never log anything on its own. The full set of key renames and rule splits Safari needs is in the dynamic rules article.
How I approach the migration
- Inventory every
webRequestlistener and write down what each one does for the user, separate from how it does it. - Sort each listener into four buckets: expressible as static rules, expressible as dynamic rules, needs redesign, or not portable. Be ruthless about the last bucket early.
- Build the rule compiler, the code that turns your source of truth (filter lists, user settings, server config) into rule sets that fit Safari’s limits.
- Rebuild user-visible feedback, counters and badges, from signals you still have. On Safari that means the DOM.
- Test per Safari version, on real sites. Every divergence in this article was found by running the real rule set, and none of them announced themselves.
DNR gives you less power and more predictability. For most extensions the user-facing feature set survives the move. The engineering underneath changes enough that the blocking layer is the part of a Safari port that most deserves a specialist.
More guides
- Getting your Chrome extension onto Safari and iPhone: the complete guideWhat the converter does, what breaks, DNR, signing, and App Review.
- The converter ran fine. So why is your extension broken?Six silent failure modes of converted extensions and how to diagnose each.
- Safari extension rejected? The usual reasons, and the fixes.App Review rejections translated into fixes, plus review notes that pass.
Migrating a real extension's blocking layer?
I did this migration for an ad blocker with two million users. The port assessment maps every one of your webRequest usages to its Safari answer: kept, redesigned, or cut. You get a fixed quote for the work.