WKWebView on macOS: Navigation, Content Processes, and Message Ownership
Operate WKWebView with explicit navigation policy, retained delegates, process termination recovery, bounded script bridges, and data-store choices.
WKWebView embeds web content in a native app, but its lifecycle is not the lifecycle of an NSView alone. Navigation is asynchronous, web content runs in a separate process, JavaScript can cross an explicit message bridge, and website data lives in a configured data store. A robust host treats each as an independent boundary and never assumes that a loaded page, a native view, and a live content process are the same state.
This article is about macOS app embedding, not building a general-purpose browser. Decide whether the feature displays trusted app-owned content or navigates to arbitrary sites. That distinction changes the navigation policy, script access, persistent data expectations, and recovery behavior. Do not expose native capabilities to untrusted pages merely because the web view is visually part of a trusted window.
Construct configuration before the web view
Create a WKWebViewConfiguration before constructing the view. The configuration holds the website data store, user content controller, and other behavior that should be fixed at initialization. Choose the default or nonpersistent data store based on product requirements. A nonpersistent store changes website data retention; it does not automatically prevent every form of application-level persistence or telemetry.
Keep the web view and its coordinator owned by the window or document that presents it. navigationDelegate and uiDelegate are weak references, so the object that implements them must be retained elsewhere. If a representable or controller creates the delegate only as a temporary local, callbacks may stop when that object is released.
Model navigation as a state machine
Implement WKNavigationDelegate to observe provisional navigation, redirects, response policy, commit, completion, and failures. A navigation can fail before a response arrives or after it has begun. Keep a request or navigation generation and ensure that an older completion cannot mark a newer URL as loaded. Update progress and error UI from delegate events, not from a timer or from the fact that load(_:) returned a navigation object.
import AppKit
import WebKit
final class WebPane: NSObject, WKNavigationDelegate {
let webView: WKWebView
private var generation = 0
override init() {
webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
super.init()
webView.navigationDelegate = self
}
func load(_ url: URL) {
generation += 1
webView.load(URLRequest(url: url))
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// Publish completion only if it still matches the active UI request.
NSLog("Navigation finished: %@", String(describing: webView.url))
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
// Reconcile visible state and offer a bounded, policy-aware reload.
NSLog("Web content process terminated")
}
}
The example retains the delegate through WebPane and intentionally does not reload forever after process termination. Production code should also implement failure callbacks, reject or allow navigation based on the feature’s origin policy, and decide what visible state to restore after a content process restart.
For an app-owned document viewer, allow only expected origins or schemes and route external destinations to the user’s browser. Validate both navigation action and response where a policy depends on the final destination or response type. A redirect can change the destination after the original action was approved. Do not use string-prefix checks such as hasPrefix("https://trusted.example"); parse the URL and compare its scheme, host, and any required path boundary.
Keep navigation decision handlers single-shot. Every delegate path that receives a decision handler must invoke it exactly once, including paths that reject navigation or encounter an error. A forgotten completion can leave navigation stalled; invoking it twice violates the delegate contract. Centralize policy in a small function that returns allow, cancel, or download behavior, then complete the handler on every branch. Test redirects and downloads separately from ordinary page loads.
Distinguish main-frame navigation from subframe activity. A trusted top-level page can embed third-party resources, and a frame can attempt to navigate independently. Use the frame and security-origin information provided by WebKit when the product’s policy depends on who initiated the action. Do not infer that all script or resource traffic belongs to the top-level URL. If the product is a browser-like surface, explain that navigation filtering is not a substitute for network-layer content security.
Handle process termination without stale state
WebKit can terminate the content process independently from the native view. The delegate callback is a recovery signal, not proof that the page can be retried unchanged. Preserve the intended URL and user-visible state separately from transient web process state. Reload only when the content is safe and the retry policy allows it; cap automatic retries and provide a visible reload action when appropriate.
After process loss, re-check script bridge assumptions, pending native promises, navigation generation, and loading indicators. A callback from an old process must not fulfill a request that belongs to a newer page. Avoid replaying form submissions or non-idempotent navigation automatically. For app-owned content, a reload can rebuild derived page state; for arbitrary external sites, let the user decide.
Maintain a small recovery state machine such as active, process-terminated, reloading, and failed. Keep the last committed app-level route distinct from a provisional navigation URL; otherwise, a crash in the middle of a redirected load may restore the wrong destination. Persist only what the feature can safely reconstruct. Do not attempt to serialize the entire web process, JavaScript heap, or page DOM as durable app state.
Set a retry budget and reset it only after meaningful progress, such as a successful commit or explicit user action. Automatic reloads can create loops when a page consistently exhausts resources. If the process repeatedly terminates, stop retrying, preserve the visible error explanation, and collect diagnostic context such as the URL origin, navigation generation, and recent memory behavior without capturing page contents or credentials.
Script messages are an API boundary
Configure WKUserContentController before the view and add only the script handlers the feature needs. A message handler is callable from page JavaScript; validate the frame, security origin, message name, payload shape, size, and current navigation before acting. Treat every web message as untrusted input. Prefer a narrow command set with explicit parameter schemas over a generic “execute native action” bridge.
Remove handlers when the owning view or feature ends, and avoid retain cycles between the web view, content controller, and native coordinator. Where supported by the deployment target, use content worlds to separate app-injected scripts from page scripts, while remembering that a content world is not an authorization system for native actions. Revalidate permissions in the native layer even when JavaScript appears to originate from a trusted page.
Website data, downloads, and user expectations
Choose the website data store deliberately: persistent data is appropriate when users expect sign-in or preferences to survive reopening; a nonpersistent store can serve transient previews. If multiple views share a persistent store, recognize that they share website state. Clear or partition data only according to a documented user-facing policy, not as an accidental side effect of creating a new window.
Downloads, file uploads, authentication challenges, popups, and external links need explicit handling. Do not assume all browser behaviors appear in a WKWebView automatically. Confirm any response that becomes a download and route it through the correct destination flow. Avoid logging cookies, authorization headers, message payloads, full private URLs, or page text.
Accessibility, performance, and tests
Keep native controls available to VoiceOver and make web loading, failure, and offline states understandable outside the page. Avoid overlaying native controls that intercept the web view’s keyboard or accessibility navigation without a clear reason. Web content sizing is asynchronous; use constraints or an explicit content-size contract rather than repeatedly forcing the frame from JavaScript messages.
Test initial load, redirect, TLS or network error, navigation denial, response denial, process termination, repeated termination, stale completion, handler removal, malicious or oversized message payload, nonpersistent store behavior, and external link routing. Measure first-content latency, process restarts, navigation failures, message volume, and memory across repeated view creation and teardown. A good WKWebView integration owns delegates and bridges explicitly, constrains navigation, and treats the web content process as restartable.
Related:
- NSWorkspace on macOS: Open URLs and Hand Off Work Reliably
- AppKit Application Lifecycle on macOS: Launch, Open Events, and Termination
Sources: