WKWebView
view controller
memory leak
iOS development
Swift programming

WKWebView causes my view controller to leak

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When a view controller with WKWebView never deallocates after dismissal, the issue is almost always a retain cycle. The most frequent cycle involves script message handlers, closures, or observers that hold strong references. Fixing this requires explicit lifecycle cleanup, not only hiding the screen.

Common Retain Cycle Topology

A typical leak chain is:

  1. View controller strongly owns WKWebView.
  2. Web view configuration owns WKUserContentController.
  3. User content controller strongly owns script message handler.
  4. Handler strongly references view controller.

If the controller registers itself directly as handler and never removes it, deallocation will not happen.

Use a Weak Script Handler Proxy

A weak forwarding proxy breaks the strongest cycle path.

swift
1import UIKit
2import WebKit
3
4final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
5    weak var target: WKScriptMessageHandler?
6
7    init(target: WKScriptMessageHandler) {
8        self.target = target
9    }
10
11    func userContentController(
12        _ userContentController: WKUserContentController,
13        didReceive message: WKScriptMessage
14    ) {
15        target?.userContentController(userContentController, didReceive: message)
16    }
17}
18
19final class BrowserViewController: UIViewController, WKScriptMessageHandler {
20    private var webView: WKWebView!
21
22    override func viewDidLoad() {
23        super.viewDidLoad()
24
25        let config = WKWebViewConfiguration()
26        let userController = WKUserContentController()
27        userController.add(WeakScriptMessageHandler(target: self), name: "bridge")
28        config.userContentController = userController
29
30        webView = WKWebView(frame: .zero, configuration: config)
31        view.addSubview(webView)
32        webView.frame = view.bounds
33    }
34
35    func userContentController(
36        _ userContentController: WKUserContentController,
37        didReceive message: WKScriptMessage
38    ) {
39        print("received", message.name)
40    }
41}

If you prefer direct handler registration, you must remove handler explicitly on teardown.

Teardown Must Happen Before Deinit

Waiting for deinit is too late if a cycle already exists. Add cleanup in lifecycle methods that run on dismissal.

swift
1override func viewDidDisappear(_ animated: Bool) {
2    super.viewDidDisappear(animated)
3
4    if isMovingFromParent || isBeingDismissed {
5        webView.stopLoading()
6        webView.navigationDelegate = nil
7        webView.uiDelegate = nil
8        webView.configuration.userContentController.removeScriptMessageHandler(forName: "bridge")
9        NotificationCenter.default.removeObserver(self)
10    }
11}

This proactively breaks references even when dismissal path is not linear.

Watch Closure Captures and Timers

Leaks are not only about message handlers. Closures, timers, and async tasks often capture controller strongly.

swift
1func loadPage() {
2    someAsyncOperation { [weak self] success in
3        guard let self else { return }
4        self.title = success ? "Loaded" : "Failed"
5    }
6}

Use weak captures for callbacks owned longer than the controller lifecycle.

For Timer, invalidate on teardown and avoid strong self capture in timer blocks.

Debugging Workflow in Xcode

A practical leak-debug flow:

  1. Add a deinit print in controller.
  2. Present and dismiss controller repeatedly.
  3. Check whether deinit logs each cycle.
  4. Use Memory Graph to inspect retain path if deinit is missing.
swift
deinit {
    print("BrowserViewController deinit")
}

Memory Graph usually points directly to the retaining owner, such as a script handler list or closure storage.

Architecture Patterns That Reduce Leaks

For web-heavy apps, isolate bridge setup and cleanup into a reusable component. This avoids copy-paste lifecycle bugs.

Also avoid singleton web view objects carrying controller-specific bridge logic. Shared web views can work, but only when message routing and delegate ownership are deliberately separated.

A simple team practice is adding a leak regression QA step for web screens: open, interact with bridge, dismiss, repeat. This catches regressions before release.

Common Pitfalls

  • Registering controller directly as script handler and never removing it.
  • Assuming delegate nullification alone breaks all web view retention paths.
  • Using strong closure captures in async callbacks tied to web events.
  • Relying on deinit cleanup when cycle already prevents deinit.
  • Reusing long-lived web infrastructure with screen-specific ownership state.

Summary

  • Most WKWebView leaks are retain-cycle problems, not random framework defects.
  • Script message handlers are the highest-risk ownership point.
  • Use weak proxy handlers or explicit handler removal during dismissal.
  • Clean delegates, observers, and async captures in one predictable teardown path.
  • Verify fixes with deinit logs and memory graph inspection.

Course illustration
Course illustration

All Rights Reserved.