I build Kipzy, an iPhone browser with a built-in ad blocker. The blocker uses EasyList and EasyPrivacy, which works out to about 130,000 WebKit content blocking rules. This post covers how Kipzy gets those rules into every WKWebView without hurting launch time and without recompiling when a user turns blocking off for one site.
All numbers below come from the real lists. The compile times were measured on an M3 Pro Mac, which uses the same content blocker engine as iOS. An iPhone is slower.
The problem
WKContentRuleListStore.compileContentRuleList turns a JSON rule list into WebKit's compiled bytecode. It's fast for a few hundred rules. Kipzy's 7 lists add up to about 18 MB of JSON, and compiling them takes this long:
network-1 30000 rules 1.03s
network-2 30000 rules 0.88s
network-3 30000 rules 0.95s
network-4 30000 rules 0.80s
network-5 3753 rules 0.27s
cosmetic-generic 757 rules 0.05s
cosmetic-sites 5292 rules 0.54s
That's about 4.5 seconds on a laptop chip. If any of it happens at launch, or on the main thread, the app feels broken.
1. Compile once, then only look up
Compiled lists persist on disk. The store can give you a list back by identifier, and that lookup is much cheaper than compiling it again. So the identifier includes a hash of the list's contents:
extension FilterManifest.File {
/// Changes whenever the list's content changes.
var ruleListIdentifier: String { "\(id)-\(sha256.prefix(12))" }
}
Each launch tries a lookup first and only compiles on a miss:
for file in manifest.lists {
let identifier = file.ruleListIdentifier
if let list = try? await store.contentRuleList(forIdentifier: identifier) {
lists.append(list)
continue
}
let json = try await downloader.contents(of: file)
if let list = try await store.compileContentRuleList(
forIdentifier: identifier, encodedContentRuleList: json
) {
lists.append(list)
}
}
The real compile cost is paid once per list version, not once per launch. Stale identifiers are removed with availableIdentifiers() after a new set is active, so old compiled lists don't pile up on disk.
2. Nothing before the first frame
The blocker's start() runs after the first frame is on screen. Until the lists are ready, pages load without blocking. That tradeoff is deliberate: a user who opens a link at cold launch sees the page right away, and every page load after that is blocked.
The async compileContentRuleList and contentRuleList(forIdentifier:) APIs do their work inside WebKit, off your thread. Kipzy's ContentBlocker is @MainActor, and that's fine, because it only awaits them. The heavy work (parsing and compiling about 18 MB of JSON) never runs on the main thread.
3. Don't bundle the lists; build them on a server
EasyList changes every day. Bundling a converted copy in the app would mean shipping an App Store update to fix a broken filter. Instead, a Node script converts EasyList and EasyPrivacy into WebKit JSON during the website build. It publishes a manifest with each file's SHA-256 hash:
{
"format": 1,
"lists": [
{ "id": "network-1", "file": "network-1.7dee7963571a.json", "sha256": "7dee79…", "rules": 30000 },
...
]
}
The app checks the manifest at most once a day. It downloads only files whose hash it doesn't already have and verifies each hash before writing it to disk. Because the file names include the hash, a CDN can never serve a stale list under a new manifest.
4. Split the lists, and repeat the exceptions in each one
Kipzy splits network rules into chunks of 30,000. Two reasons:
-
One invalid rule fails the whole list. WebKit rejects the entire JSON if any trigger is invalid. With smaller lists, a converter bug costs one chunk, not all ad blocking. That's also why the converter skips any ABP syntax that WebKit can't express (regex alternation,
$csp,$redirect, entity domains likegoogle.*) instead of approximating it. -
Exceptions only apply within their own list. An
ignore-previous-rulesaction only affects rules earlier in the same list. Each network chunk therefore ends with all of the@@exception rules. Without that, an allowlisted request in chunk 1 could still be blocked by chunk 3.
const chunkSize = maxRulesPerList - networkExceptions.length
for (let i = 0; i < network.length; i += chunkSize) {
lists.push({
id: `network-${lists.length + 1}`,
rules: [...network.slice(i, i + chunkSize), ...networkExceptions],
})
}
Cosmetic rules are packed differently. Generic ## selectors are grouped 50 per css-display-none rule, and site-specific selectors are grouped by domain set, so each site costs one rule instead of dozens.
5. Per-site "off" without recompiling
Users can turn blocking off for one site. The obvious way is to recompile with an unless-domain rule, which takes seconds. Kipzy swaps the lists on that web view's WKUserContentController right before each main-frame navigation instead:
public func prepare(_ controller: WKUserContentController, for url: URL, isPrivate: Bool) {
guard let state = controllers.object(forKey: controller) else { return }
state.page = (url, isPrivate)
apply(to: controller)
}
private func apply(to controller: WKUserContentController) {
guard let state = controllers.object(forKey: controller) else { return }
let adsOn = state.page.map { blocksAds(on: $0.url, isPrivate: $0.isPrivate) } ?? settings.blockAds
let wanted = adsOn ? generation : -1
guard state.generation != wanted else { return }
controller.removeAllContentRuleLists()
if adsOn { ruleLists.forEach(controller.add) }
state.generation = wanted
}
A generation counter increases whenever the active lists change. A controller that already has the right lists does nothing, so normal navigation costs one integer comparison. Controllers are tracked in an NSMapTable with weak keys, so closed tabs drop out by themselves.
6. Check the lists before you ship them
macOS uses the same content blocker engine, so a short Swift script compiles every list with WKContentRuleListStore on a Mac. That's where the timing table above came from. Running it after a converter change catches a list that won't compile before any phone downloads it.
swift scripts/filters/verify.swift
Summary
- Put a content hash in the rule list identifier; look up first, compile only on a miss.
- Start the blocker after the first frame and use the async APIs.
- Build and host the lists yourself, with hashed file names.
- Keep lists small, and put the exceptions at the end of every list.
- Turn blocking off for a site by swapping lists on the content controller, not by recompiling.
- Compile every list on macOS to catch broken ones before they ship.
If you want to see the result, Kipzy is free on the App Store (iOS 17+). The blocker, private tabs and Privacy Report are all free. You can find more at kipzy.app.
Top comments (0)