I have spent the last few months building a browser on WKWebView. Not a wrapper around a single web view — a real one, with tabs, separate cookie jars per workspace, downloads, extensions, the lot. WKWebView turns out to be a very good engine to build on and a slightly under-documented one. The class references are accurate about the API surface. What they do not tell you is what happens when you use several parts of it together.
Here are the six things that cost me the most time. All macOS, though most of it applies to iOS too.
1. WKProcessPool does nothing anymore
Every tutorial written before about 2021 tells you to share a WKProcessPool between web views so they share a process. Apple deprecated it in macOS 12 and it no longer influences process sharing at all. Creating one is dead weight.
What actually decides process sharing now is the data store. Web views that share a WKWebsiteDataStore share WebKit's network and content processes. Web views with different stores do not.
That is a more useful lever than the process pool ever was, because it means isolation and cost are the same decision. If you want two groups of tabs genuinely separated — different cookies, different local storage — give them different stores and accept another set of processes. If you want them cheap, give them the same store.
2. WKWebsiteDataStore(forIdentifier:) returns a new object every time
macOS 14 added persistent, named data stores, which is the API you want if you are building anything with profiles:
let store = WKWebsiteDataStore(forIdentifier: uuid)
Call it twice with the same UUID and you get two distinct objects pointing at the same data on disk. Nothing crashes. Nothing warns you. That is exactly the problem: you have silently given up the process sharing from point 1, because as far as WebKit is concerned those web views are in different stores.
So cache them, and hand the same object out every time:
private var stores: [Identity: WKWebsiteDataStore] = [:]
func dataStore(for identity: Identity) -> WKWebsiteDataStore {
if let existing = stores[identity] { return existing }
let store: WKWebsiteDataStore = switch identity {
case .standard: .default()
case .isolated(let id): WKWebsiteDataStore(forIdentifier: id)
case .ephemeral: .nonPersistent()
}
stores[identity] = store
return store
}
3. You cannot delete a data store while anything still holds it
WKWebsiteDataStore.remove(forIdentifier:) throws Data store is in use (by network process) if any reference to that store is still alive. That includes the one in the cache you just built in point 2, and it includes a web view you have already pulled out of the view hierarchy but are still holding somewhere.
Retrying on its own does not fix it — I measured that, and the retries fail for as long as the reference exists. What works is dropping your last reference first, then retrying a few times to cover the gap between the release and the deallocation actually landing:
releaseDataStore(for: identity) // drop the cached object
for attempt in 1...5 {
do {
try await WKWebsiteDataStore.remove(forIdentifier: identifier)
return
} catch {
if attempt == 5 { return }
try? await Task.sleep(for: .milliseconds(250))
}
}
And the asymmetry worth knowing: you cannot remove the default store at all. For that one you empty it with removeData(ofTypes:modifiedSince:) instead. Two different APIs for the same user-facing "delete everything", and you need both if you support more than one kind of profile.
4. window.open hands you a configuration, and you have to use it
The delegate method looks harmless:
func webView(_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures) -> WKWebView?
It is tempting to ignore that configuration and build the new web view with your own, because your own is the one with all your content scripts and settings attached. Do not. The new web view has to be created with the configuration WebKit handed you, or the opener relationship breaks: window.opener comes back null, named targets stop finding the window, and OAuth popups that post back to their opener quietly fail.
So the shape you want is a function that wraps someone else's configuration rather than one that always makes its own:
func makeWebView(frame: NSRect, configuration: WKWebViewConfiguration) -> WKWebView {
let webView = WKWebView(frame: frame, configuration: configuration)
webView.allowsBackForwardNavigationGestures = true
webView.allowsMagnification = true
return webView
}
Anything you set on the WKWebView itself is fine here. Anything you attach at configuration time — user scripts, message handlers, content rules — has to be attached in that delegate too, because this configuration never went through your own setup path.
5. The web inspector has two switches, for two different things
webView.isInspectable = true (macOS 13.3+) is what lets Safari's Develop menu attach to your web views. It is not what puts Inspect Element in the page's own right-click menu. That is still the old preference, set by key:
webView.isInspectable = true
configuration.preferences.setValue(true, forKey: "developerExtrasEnabled")
You need both if you want the context menu item inside your own app. I spent an embarrassing afternoon on this one, convinced isInspectable was broken.
6. Downloads live on the web view, not on WKDownload
WKDownload looks self-contained, and then you try to implement retry. The two methods that start a download — resumeDownload(fromResumeData:) and startDownload(using:) — are methods on WKWebView, not on WKDownload. So "retry this download" means keeping a live web view around for it, potentially long after the tab that started it has been closed.
The second half of the same problem: WKDownload.delegate is weak, so something has to own it. One delegate serving every transfer is far less bookkeeping than one per transfer — every callback hands you the WKDownload back, so you can look up which row it belongs to:
func download(_ download: WKDownload,
decideDestinationUsing response: URLResponse,
suggestedFilename: String) async -> URL? {
row(for: download)?.destination
}
The pattern
Five of these six are the same mistake in different clothes: treating a WebKit object as if it were a value you can make freely, when it is really a handle to a shared, process-backed resource with a lifetime. The data store, the configuration, the download — WebKit hands them to you, and the right move is almost always to hold on to exactly one of the thing and pass it around, rather than to construct a fresh one because constructing one is easy.
If you have hit a seventh, I would genuinely like to hear it.
All of the above came out of building Kylmora, a small open-source macOS browser — AppKit over the system WebKit, no bundled engine. The source is on GitHub if you want to see any of these in context.
Top comments (0)