macOS: WKWebView renderer is suspended after ~16 min in background and fails to recover under memory pressure — window becomes a dead shell, force-quit required

Author: hraywang-jpgCreated Sep 16, 2026Updated Sep 16, 2026

Summary

On macOS, if OpenWorker sits in the background for ~16 minutes, WebKit suspends the WebContent (renderer) process and swaps it out. Returning normally resumes it within a second — but when the machine is under memory pressure the renderer never comes back, and the window is left as an unresponsive shell (beachball, clicks ignored). Only force-quit + relaunch recovers, which kills any running session.

Not a crash, and openworker-server stays healthy (1 ms /v1/health, no 5xx) throughout — so this is invisible in sidecar logs.

Environment

  • OpenWorker 0.2.1 (packaged .app, com.openworker.desktop), Tauri + WKWebView
  • macOS 14.8.4, 8 GB RAM, uptime 19 days

Root cause (app's own unified log)

Loss of focus releases WebKit's foreground assertion (flips 1:1 with app activity):

WebPageProxy::updateThrottleState: UIProcess is releasing a foreground assertion
because the view is no longer visible
WebProcessCache::setApplicationIsActive: (isActive=0)

~16 minutes later WebKit suspends the renderer — a fixed timer, measured twice the same day at 16 min 03 s and 16 min 04 s:

ProcessThrottler::dropNearSuspendedAssertionTimerFired: Removing near-suspended process assertion
WebProcessProxy::didChangeThrottleState(Suspended)

The difference is memory, not the suspension

Recovers (memory headroom): after quitting Chrome (swap 4.9 GB → 0.5 GB, free pages 48 MB → 567 MB), three suspensions in one afternoon all recovered, two of them spanning full system sleep — dropNear… at 13:14 → Sleep → ProcessDidResume 6 s after wake; again 14:05 → Sleep → resume 6 s after wake. Same process, never restarted, window usable.

Never recovers (memory exhausted): swap 4,887/6,144 MB, ~48 MB free, ~2.5 GB compressed. After Received memory pressure event 2 vm pressure 0, the suspended renderer was never paged back in: 9 clicks over ~2 minutes produced only setApplicationIsActive flapping and no ProcessDidResume; a snapshot during the freeze shows the renderer alive with RSS ~2 MB (fully swapped out). The Rust main thread was idle in mach_msg2_trap — no deadlock, the shell is the renderer.

Ruled out

  • Not a crash — all 15 exits in 3 days were user Cmd+Q; no crash reports.
  • Not the sidecar — polled normally right up to the suspension instant.
  • Not sleep, not occlusion — reproduced with no sleep events; WebKit keys off app activity, not whether the view is covered.
  • Keepalive doesn't help — 10 s polling + live WebSockets, renderer still suspended.
  • NSAppSleepDisabled doesn't help — suspensions still occurred at the same cadence; the afternoon that worked fine had it unset.

Related

The same suspension signature hit Safari, a WebKit web app, and clash-verge (also Tauri). So it's a Tauri/wry-class issue; what makes it severe here is that OpenWorker's window is the only entry point and, unlike Safari, the view is not rebuilt afterwards.

What we're asking for

  1. Don't suspend the renderer while work is in flight. Hold an activity assertion (NSProcessInfo -beginActivityWithOptions:) for a running session, or expose the equivalent of Electron's backgroundThrottling: false for the WKWebView — we understand wry may not expose this today; that's the gap.
  2. Recover after suspension instead of leaving a dead shell. Safari rebuilds/reloads the web content process and the user notices nothing. At minimum, re-establish the WebSocket and re-fetch state on renderer suspension/termination.
  3. Don't mark the app as automatically terminable. _kLSApplicationWouldBeTerminatedByTAL is set to 1 on every focus loss — a latent data-loss risk for an app running long tasks.

Repro

  1. On an 8 GB Mac, create memory pressure (Chrome with ~15 tabs open alongside OpenWorker): swap ≳ 4 GB, free pages < 100 MB.
  2. Start a session, switch away for > 16 minutes, then return and click — unresponsive shell, no ProcessDidResume, renderer RSS stuck ~2 MB. Force-quit is the only way out.
  3. Control: free memory, repeat — the same suspension now recovers in ~1 s.

Full unified-log capture, main-thread sample, and process/memory snapshots at the moment of the freeze are available on request.