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
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