DEV Community

Kylmora
Kylmora

Posted on

Six things about WKWebView the docs don't tell you

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)