Skip to content

iPhone Safari WebContent process killed by Jetsam after ~60-90s of continuous play (per-process-limit) #1220

Description

@UncountedOne

Symptom
On iPhone Safari (any iOS 17+ device tested), any libretro core hits an iOS Jetsam kill within 60-90 seconds of continuous play. The Safari WebContent process is terminated with reason per-process-limit, the tab visibly reloads back to the host site, and the user has to navigate back to the game. Reproduces on every NES core (FCEUmm + Nestopia), the SMB / Bubble Bobble / Duck Hunt titles, and a variety of NES ROMs. N64 (mupen64plus_next) on the same device is significantly more stable for reasons described below.

Reproduction

Open any EmulatorJS-hosted page on iPhone (tested iOS 17.x, 18.x).
Launch any NES game.
Play normally — even sitting on the title screen with no input triggers the same kill timer.
Around the 60-90 second mark the tab reloads with no warning.
Same code on Mac Safari runs indefinitely. Same code on Android Chrome runs indefinitely. iOS-only.

Diagnosis from the user side

We instrumented EmulatorJS very heavily over ~20 iterations. Key findings:

WASM heap is flat. The libretro core's linear memory (probed via self.Module.HEAP8.length inside the worker realm) sits at ~16 MB for the entire session, no growth.
JavaScript heap is flat. Recorded via Safari Web Inspector Memory tab — javascript category sits at ~280-300 MB across the whole session.
JIT memory is flat. ~7-8 MB stable.
EJS's WebGL renderer is doing the right thing. We patched WebGLRenderingContext.prototype.{createTexture,deleteTexture,texImage2D,texSubImage2D} and counted calls per 2-second interval. Steady state showed:
createTexture: 0 / 2s
deleteTexture: 0 / 2s
texImage2D: 0 / 2s
texSubImage2D: 120 / 2s (= 60 fps × 2s, perfect texture recycling)
EJS creates textures once at startup and writes new pixels into them via texSubImage2D every frame — the textbook-correct WebGL recycling pattern.
The leak is in WebKit's page memory category (compositor / canvas backing stores). Web Inspector Timeline → Memory recording shows this category growing linearly from ~100 MB at game launch to ~2.0 GB at t=71s, then iOS fires Jetsam. Growth rate: ~14 MB/sec, which is exactly one NES framebuffer (256×240×4 = 245 KB) at 60 fps.
Root cause hypothesis

iOS WebKit's compositor retains GPU backing-store frames from any hardware-promoted element under sustained WebGL draws, at a rate roughly equal to the framebuffer size × frame rate. The accumulated backing stores are billed to the WebContent process's per-process-limit (typically 1.0-1.5 GB on modern iPhones), so the page is killed when accumulation crosses the threshold. N64 doesn't hit the limit as fast because mupen64plus_next renders directly via GL at lower effective FPS on iPhone, while NES cores upload a CPU-side framebuffer at locked 60Hz.

Confirmation that EJS itself is not the bug

Replacing FCEUmm → Nestopia at the EJS level (EJS_core swap) produced identical crash timing.
Forcing preserveDrawingBuffer: false, antialias: false, alpha: false, desynchronized: true on the WebGL context via HTMLCanvasElement.prototype.getContext monkey-patch produced identical crash timing.
The WebGL texture call profile confirms EJS isn't allocating per frame.
Mac Safari runs the identical EJS code indefinitely — same WebKit codebase but with a vastly larger per-process memory budget that hides the leak.
Possible workarounds on EJS's side (if any of these are feasible)

These are suggestions, not requirements — totally understand if WebKit's behavior is outside EJS's scope:

OffscreenCanvas + transferToImageBitmap rendering path for the libretro framebuffer. This bypasses the on-screen canvas's hardware-promoted compositor layer and might not trigger the same backing-store retention.
An opt-in 2D-canvas software-render mode for very-low-resolution systems (NES, GB, GBA). For a 256×240 NES framebuffer, software RGBA blit at 60Hz is well under 1% CPU on modern phones and would entirely avoid the GPU compositor path.
A built-in periodic "core restart" mode (capture state → tear down WebGL context + worker → reload core → restore state) gated by EJS_iOSAggressiveMemory or similar. This is what we ended up implementing on our side as an opt-in workaround.
A user-facing "iOS limitation" notice in the EJS launcher when iPhone/iPad is detected, so authors don't waste time hunting a non-existent EJS bug.
Related WebKit signals

The Console.app system log on the iPhone shows the kill cleanly:

ReportSystemMemory Process com.apple.WebKit.WebContent killed by jetsam reason per-process-limit
SafariViewService Firing exit handlers for ... <RBSProcessExitContext| ... domain:jetsam(1) code:per-process-limit(7)>
We did not file a WebKit bug as we don't have a minimal reproducer outside EJS yet — but if EJS would find that useful for backing this up, we can build one.

Versions tested

EmulatorJS: cdn.emulatorjs.org/stable/data/ (live as of late May 2026)
iPhone iOS 18.7, Safari 26.5
iPhone iOS 18.x (multiple test devices)
Confirmed working: macOS Safari (same WebKit base), Android Chrome

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions