Phone performance for a plain-JS canvas game: limits, speed, workers, storage and measuring
Notes compiled 2026-11-05 for Elsewhere and the Tycho study. Target: a plain-JavaScript pixel game at 320×180, painted into palette-indexed buffers and drawn with canvas 2D (Tycho’s GPU stand-in), with seeded world generation on the order of the study’s demos (a 256×256 or 512×512 world, quest-sized regions). The question: what do real phones allow, how fast are they against the laptop the study’s numbers were measured on, and how should generation, saving and measuring be budgeted. Sibling notes with phone-relevant numbers: realistic_world_generation.md and world_generation_bench.js (laptop timings of the generation stages), falling_sand_materials.md (section 4, sand on phones), lod_zoom_chunks.md (Merryfolk’s per-frame painting budget and chunk memory).
Note on sourcing: WebKit’s blog posts, WebKit Bugzilla comments by WebKit engineers, MDN and Chrome’s developer docs were fetched and read. Phone benchmark scores come from Notebookcheck’s reviews (fetched; the tables were read through a summarizer, so treat the last digit with care). The laptop’s JetStream score and every workload time marked “measured” were measured for this note on the study’s reference machine. Items marked “(search summary)” come from search-result summaries only.
The reference machine. A 2019 MacBook Pro 16-inch: Intel Core i9-9880H at 2.3 GHz (8 cores, 16 threads), 32 GB, macOS 15.7.9, Chrome 157 headless. sysctl -n machdep.cpu.brand_string gives “Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz”. JetStream 2.2 on it scored 213 (measured, one run, browserbench.org/JetStream2.2, headless Chrome 157.0.8103.38). This is the same machine as the geography page’s bench (Node v26.8.1 there).
1. Hard limits: canvas, memory, GPU APIs
Takeaway
A 320×180 game is nowhere near any canvas limit. What bites on iOS is total memory: Safari keeps canvases alive after a page drops them, a WebContent process can be killed by jetsam past a soft limit near 1.5 GB (lower on phones with less RAM), and a killed tab reloads with “A problem repeatedly occurred”. Big prebaked chunk caches (Merryfolk’s 117 MB of canvases) are the realistic risk, not the frame. WebGL2 is everywhere since Safari 15 (2021), OffscreenCanvas 2D in workers since Safari 16.4 (2023), WebGL in OffscreenCanvas since Safari 17 (2023), WebGPU since Safari 26 (2025).
Cited findings
- Canvas area on iOS. WebKit’s own iOS layout-test expectations print “Canvas area exceeds the maximum limit (width * height > 16777216)”, i.e. 4096×4096 pixels. — WebKit test expectation
- iOS 18 raised it. One developer measured 4096×4096 (16,777,216 px) on iPhone 13s running iOS 17.7.2 and 8192×8192 (67,108,864 px) on iOS 18.0, with the canvas-size package on BrowserStack and a personal phone; an iPad on iOS 17.7.5 still had the old limit. Single source, measured, not from Apple’s notes. — lionpuro, “Canvas is finally usable on Safari”, May 2025
- MDN: “notably iOS devices limit the canvas size to only 4,096 x 4,096 pixels”; “Exceeding the maximum dimensions or area renders the canvas unusable — drawing commands will not work.” Per-browser numbers are in the canvas-size project’s test results. — MDN, canvas element; canvas-size
- Total canvas memory on iOS. The warning “Total canvas memory use exceeds the maximum limit” appears when all live canvases together pass a cap; pqina reports “It’s limited to 384MB on version 15”, lower on earlier versions and probably device-dependent; the fix is to shrink each canvas to 1×1 and clear it when done. — pqina. A Stack Overflow report measured 256 MB on an iPad and 288 MB on an iPhone X; there is no official Apple document (search summary, via Babylon.js forum).
- Canvases outlive their page references on iOS. WebKit bug 195325: canvas elements without a strong reference are not collected promptly on iOS 12; setting width and height to 0 before dropping them avoids it. — WebKit bug 195325 (search summary)
- WebContent memory limit. Simon Fraser (WebKit), 2024-08-09: “WebContent processes on iPhone are subject to a “soft” limit of around 1.5GB”, past which they “are subject to getting killed by “jetsam””; “It will be somewhat lower on iPhone models with less RAM.” Enforcement depends on overall device memory pressure. — WebKit bug 277848
- Kill and reload. WebGL- and texture-heavy pages “get terminated by iOS memory pressure and immediately reloaded by Safari”, showing “A problem repeatedly occurred”; the reporter asks for a warning signal and a documented budget (none exists). — WebKit bug 300782. An April 2026 report of a long page with many animated iframes jetsammed on an iPhone 17 Pro; hiding elements with
display: nonedidn’t help. — WebKit bug 312727 (search summary) - Android kills background tabs. Chrome’s blog: on Android, “Chrome kills background tabs aggressively in order to ensure memory usage is low.” — Chrome, tab discarding (search summary). The Page Lifecycle API: “treat the hidden state as the likely end to the user’s session”; on mobile “the beforeunload, pagehide, and unload events are not fired” when a tab or the app is closed; a discarded page can’t run code, so check
document.wasDiscardedon load. — Chrome, Page Lifecycle API - WebGL2 on iOS: Safari 15 (WebKit post 2021-10-26): “WebKit now supports WebGL2”, and “the WebGL implementation now runs on top of Metal”. Same release: ES modules in workers and service workers; Safari 15.1 adds
performance.timeOriginin workers. — WebKit, Safari 15 - OffscreenCanvas: Safari 16.4 (2023-03-27) “added Offscreen Canvas support for 2D operations”, and “Rendering can now also be transferred to a worker context”; 3D wasn’t in yet. Safari 16.4 also added Compression Streams, Web Push for Home Screen web apps, Badging and Screen Wake Lock. — WebKit, Safari 16.4. Safari 17.0 (2023-09-18) “adds support for WebGL in Offscreen Canvas”. — WebKit, Safari 17.0. The partial 16.4 version broke Construct 3’s feature detection; WebKit added a quirk, and a commenter found WebGL in workers on Safari 17.1 worked on Sonoma but not Ventura, so detect inside the worker. — WebKit bug 183720, bug 253431 (search summary)
- WebGPU: Safari 26 (announced 2025-06-09): WebGPU “has been enabled in Safari Technology Preview for over a year, and is now shipping in Safari 26 beta for macOS, iOS, iPadOS, and visionOS”; “WebGPU supersedes WebGL on macOS, iOS, iPadOS, and visionOS and is preferred for new sites”. Safari 26 also fixed
Math.hypot()being “significantly slower thanMath.sqrt()”. iOS 26 shipped in mid-September 2025 (search summary). — WebKit, WWDC25 - Every iOS browser is WebKit, so Chrome on iPhone has Safari’s limits; Notebookcheck’s iPhone results labeled “Chrome 117” are WebKit numbers. MDN notes that in the EU from iOS 17.4 alternative engines follow their own storage policies. — MDN, storage quotas
navigator.hardwareConcurrencyis clamped by WebKit: caniuse’s notes say WebKit returns at most 2 on iOS and 8 elsewhere (sources disagree: some say 4 or 8). Don’t size a worker pool from it on iOS. — caniuse (search summary)
Inferences
- (inference) Elsewhere’s frame is 320×180×4 = 230 KB per canvas. Even 100 prebaked 320×180 layers are 23 MB. The cap that matters is a chunk or region cache: count bytes (w×h×4 per canvas), cap them (say 64 MB on iOS), and release canvases by shrinking them to 1×1.
- (inference) Keep indexed data (Uint8Array, 1 byte a pixel) as the stored form and bake RGBA canvases only for what’s near the camera: a 512×512 world is 256 KB indexed, 1 MB as RGBA.
2. CPU: how fast phones run JS
Takeaway
On browser benchmarks the iPhone 15 and newer beat the 2019 laptop; mid-range Android runs about a third as fast, and budget Android (Helio G81, Galaxy A15) about a quarter. Budget for the slow end: a job that takes 100 ms on the laptop takes about 400 ms on a Moto G15, and about 85 ms on an iPhone 15. Phones also throttle: the Pixel 6a loses more than half its GPU throughput in 3DMark’s 20-run stress loop; the iPhone 15 keeps 76 to 84%.
Cited findings (Notebookcheck reviews; JetStream 2 total, Speedometer 2.0 runs/min; higher is faster)
| Device (browser) | JetStream 2 | Speedometer 2.0 | Speedometer 3.0 | Source |
|---|---|---|---|---|
| Reference: 2019 MacBook Pro, i9-9880H (headless Chrome 157) | 213 (JetStream 2.2, measured) | this note | ||
| iPhone 17 Pro, A19 Pro (Safari 26.0) | 329.1 | 621 | 41.7 | NBC iPhone 17 Pro |
| iPhone 16 Pro (Safari 18) | 386.7 | 569 | 33.5 | same |
| iPhone 15 Pro (Chrome 117, WebKit) | 351.1 | 396 | NBC iPhone 15 | |
| iPhone 15, A16 (Chrome 117, WebKit) | 259.7 | 327 | same | |
| iPhone 14 (Safari 16) | 189.9 | 341 | same | |
| iPhone SE 2022, A15 (Chrome 99, WebKit) | 182.6 | 239 | NBC Pixel 6a | |
| Galaxy S25 Ultra (Chrome 132) | 315.1 | 425 | 29.3 | NBC iPhone 17 Pro |
| Pixel 10 Pro (Chrome 140) | 193.3 | 345 | 20.6 | same |
| Galaxy S23 (Chrome 109) | 158.6 | 136 | NBC iPhone 15 | |
| Pixel 6 (Chrome 96) | 90.1 | 104 | NBC Pixel 6a | |
| Pixel 6a, Tensor (listed as Firefox for JetStream; Chrome 105) | 75.3 | 90.4 | same | |
| Galaxy A53 (Chrome 101) | 63.5 | 51.2 | same | |
| Galaxy A15 (Chrome 122) | 60.4 | 3.66 | NBC Moto G15 | |
| Moto G15, Helio G81 (Chrome 134) | 56.4 | 49.3 | 3.06 | same |
| Redmi A3 (Chrome 126) | 15.2 | 1.03 | same | |
| Smartphone class average (Notebookcheck) | 153.3 | 224 | 14.3 | NBC pages |
- JetStream versions differ (2.0/2.1 on older reviews, 2.2 here); scores are close but not identical across versions. Speedometer 3 includes DOM work and is a poorer proxy for a canvas game’s JS than JetStream.
- Throttling, iPhone 15: “the SoC slowed by a minimum of 15.7% and up to 24.7% in the 3DMark Stress tests after 20 runs”; Wild Life stress stability 76.3%, Extreme 84.3%; iPhone 15 Pro 84.9% and 95.9%; surface peak 43.7 °C. — NBC iPhone 15
- Throttling, Pixel 6a: the SoC “is quickly throttled under high load”; in the Wild Life stress test performance drops “by more than 55% in the worst case”; stability 42.9% (Galaxy A53 99.6%, iPhone SE 2022 79.5%). — NBC Pixel 6a
- iOS rAF caps: Low Power Mode throttles
requestAnimationFrameto 30 fps on purpose (WebKit maintainer: “a little bit rough but it is okay”); r261113 runs the display refresh monitor at 30 fps in that mode; WebKit’s layout test expects under 35 frames per second. — WebKit bug 215745, webkit-unassigned 2020-08, layout test. Cross-origin iframes are also throttled to 30 fps on iOS (2018). — Popmotion - ProMotion: Safari keeps rAF near 60 fps on 120 Hz iPhones by default; the feature flag “Prefer Page Rendering Updates near 60fps” (Settings > Safari > Advanced > Feature Flags) is on by default, and turning it off gives 120 Hz rAF on iOS 18 and later. — Tom’s Guide, WebKit bug 173434 thread (search summary). Android phones with 90/120 Hz screens run rAF at the screen’s rate in Chrome (not separately sourced; the bench demo reports it).
- Chrome Energy Saver “conserves battery power by reducing the display refresh rate”; rAF adapts, and “a loop that assumes 16.67 ms between callbacks would run at roughly half speed”. — Chrome, Memory and Energy Saver
- Battery: 60 vs 30 fps. Yan, He, Liu and Huang (Microsoft Research, HotPower 2015) measured 10 Android games on a Nexus 5 with a Monsoon power monitor: halving the frame rate from 60 to 30 cut system power by 33.3% on average, from 16.0% to 55.7%, more in 3D games (43.4% on average); dropping further to 0 fps saved little more, because the screen and CPU remain. 35 of 40 games ran at a fixed 60 fps regardless of state; 29 of 40 went (nearly) static when idle. — Yan et al. 2015
- Unity’s docs: “On mobile platforms the default frame rate is less than the maximum achievable frame rate due to need to conserve battery power. Typically on mobile platforms the default frame rate is 30FPS”; pick a target that divides the refresh rate. — Unity, Application.targetFrameRate (5.4)
Inferences
- (inference) Ratios to the laptop from JetStream: iPhone 17 Pro 0.65× the time, iPhone 15 0.82×, iPhone SE 2022 1.17×, Pixel 6a 2.8×, Galaxy A53 3.4×, Moto G15 3.8×. Budget at 4× for the slow end, and check with the bench card on real phones.
- (inference) Elsewhere’s 30 Hz fixed step (Kit’s default) is already the right shape: draw only when something moved, and drop to 30 Hz rendering for static talk scenes. Low Power Mode and Energy Saver will force 30 anyway, so a game must use real
dt.
3. Workers: moving generation off the main thread
Takeaway
Workers are universal (module workers since Safari 15). A worker made from a Blob URL needs no extra file. Startup is tens of milliseconds on phones; transferring an ArrayBuffer is near free at any size; structured clone of ~100 KB stays inside a frame even on slow phones. SharedArrayBuffer needs cross-origin isolation (COOP/COEP headers), which GitHub Pages can’t send; a service worker (coi-serviceworker) can fake the headers at the cost of a reload on first visit. For generation, transfer (not share) is enough. Chunking work across frames with a generator and a ms budget is the no-worker fallback (Merryfolk does it).
Cited findings
- Surma (2019-07-15): “even on the slowest devices, you can
postMessage()objects up to 100KiB and stay within your budget”; “everything up to 10KiB will not pose a risk to your animation budget”; transferring an ArrayBuffer is “extremely fast and independent of the size”. — Is postMessage slow? - Worker instantiation measured about 40 ms on a 2015 Firefox OS phone (Flame); “may not fit in the time allocated for a single frame”, so create few and reuse them. — Mozilla Hacks, How fast are web workers? (search summary). Mapbox found blob-URL creation “just a few milliseconds”; the cost is the worker starting and evaluating its code. — mapbox-gl-js #3153 (search summary)
performance.now()in a worker counts from the worker’s own start (timeOrigin), not the page’s; compare times across threads withperformance.timeOrigin + performance.now(). — MDN, performance.now- coi-serviceworker: “Cross-origin isolation (COOP and COEP) through a service worker for situations in which you can’t control the headers”; “This script will reload the page on the user’s first load”; it must be a separate file served from your own origin, HTTPS or localhost. — coi-serviceworker. A comment in a third-party copy says iOS Safari before 17.5 silently ignores
COEP: credentialless(search summary, unverified). - Merryfolk paints chunks with a generator (
function* paintGroundGen) under a per-frame budget: 28 ms behind the intro card, 16 ms when bare ground is on screen, 6 ms while the camera moves, 2 ms otherwise. —lod_zoom_chunks.md, from/Users/david/projects/merryfolk/js/main.js:1308-1314
Measured (this note, laptop, headless Chrome 157)
- The study-style world job (six-octave fbm on an island falloff, priority-flood, flow accumulation, paint to indexes), median of five: 256×256 in 28.4 ms (noise 18.4, flood 8.9, flow 0.5, paint 0.6); 512×512 in 111 ms (noise 68.8, flood 36.0, flow 2.5, paint 1.8). Noise is two thirds.
worker-genat 512×512: main thread 122–137 ms of work with a 124–139 ms frame gap; a fresh Blob-URL worker 133–138 ms of work, 7.5–13.6 ms startup, 0.2–3.6 ms transfer back, 142–156 ms in all, longest frame gap 18 ms; sliced at 6 ms a frame 22–23 frames and 368–374 ms in all, longest gap 18 ms. A warm worker from the same Blob URL started in a median 5.1 ms (five starts).phone-benchreference (median of three runs, ms per call): priority-flood 256² 9.08; noise 256² 17.3; falling-sand step 320×180 0.475; indexed blit 320×180 to 1280×720 0.266 (CPU side); 600 drawImage sprites 2.36 (CPU side). Timer step 0.1 ms. Canvas drawn by the GPU through ANGLE Metal (Intel UHD 630).storageround trip: a 256×256 index map, 65.5 KB, deflate-raw to 5.0 KB (8%), base64 6.7 KB; compress 7.2 ms, first IndexedDB open (creating the database) 107 ms, write 5.1 ms, read 0.9 ms, decompress 0.6 ms, localStorage write and read of the base64 1.0 ms.estimate()reported a 10.7 GB quota in the headless profile.- (inference) Scaled by the JetStream ratios: a 256² world is 18 ms (iPhone 17 Pro) to 107 ms (Moto G15); 512² is 72 to 419 ms; the geography demo’s 1.25 s world is 0.8 to 4.7 s.
4. Storage: keeping saves and generated worlds
Takeaway
A seed plus edits is the save; generated chunks are a cache. localStorage holds about 5 MiB and is synchronous; IndexedDB holds gigabytes (up to 60% of disk per origin in Safari 17+ and Chrome) but is best-effort and evicted least-recently-used. Safari deletes all script-written storage of a site after seven days of Safari use without a user interaction with it; Home Screen web apps keep their own count and aren’t expected to lose data. navigator.storage.persist() is granted silently by heuristics in Safari (being a Home Screen app) and Chrome (engagement). CompressionStream (Safari 16.4+, Chrome 80+) compresses a chunk in a few ms.
Cited findings
- WebKit storage policy (2023-08-10, Safari 17 / iOS 17): “For a browser app, the origin quota is up to 60% of the total disk space”; overall up to 80%; other apps 15% and 20%; a standalone Home Screen web app “has the same origin quota and overall quota as when it is opened in a browser app”; eviction is per origin, least recently used, and origins with an active page or persistent mode may be excluded; persist() is granted “based on heuristics like whether the website is opened as a Home Screen Web App”; quota from
StorageManager.estimate()is an upper limit, not a guarantee. Before Safari 17 an origin started at 1 GB and Safari prompted. — WebKit, Updates to Storage Policy, Safari 17.0 - Seven-day cap (2020-03-24): with ITP, Safari deletes all script-writable storage (IndexedDB, LocalStorage, media keys, SessionStorage, service worker registrations and cache) “after seven days of Safari use without user interaction on the site”. Home Screen web apps keep their own day counter, reset by using the app: “We do not expect the first-party in such a web application to have its website data deleted.” — WebKit, Full Third-Party Cookie Blocking and More. MDN: “If an origin has no user interaction, such as click or tap, in the last seven days of browser use, its data created from script will be deleted. Cookies set by server are exempt.”
- MDN quotas: Web Storage “is limited to 10 MiB of data maximum on all browsers”; “Browsers can store up to 5 MiB of local storage, and 5 MiB of session storage per origin”; exceeding throws
QuotaExceededError. Chromium: an origin “can store up to 60% of the total disk size” in either mode; the browser uses at most 80%. Firefox best-effort: the smaller of 10% of disk or 10 GiB; persistent up to 50%. Safari and Chromium approve persist() “based on the user’s history of interaction with the site and do not show any prompts”; Firefox prompts. Eviction is all of an origin at once. — MDN, storage quotas - Compression Streams: gzip, deflate and deflate-raw; Chromium 80+, Safari 16.4+. — Chrome, Compression Streams API, WebKit, Safari 16.4
- Safari 26 blocks known fingerprinting scripts from “long-lived script-written storage such as cookies or LocalStorage” (doesn’t affect a first-party game). — WebKit, WWDC25
Inferences
- (inference) Save the seed, the quest state and the player’s edits in localStorage (small, synchronous, written on
visibilitychangeto hidden); keep regenerable chunks in IndexedDB as compressed indexed bytes, and treat any missing chunk as “regenerate”. A world that can always be rebuilt from its seed survives eviction. - (inference) Ask for
persist()once the game is installed to the Home Screen (where Safari grants it) and after the player has played (Chrome’s engagement heuristic).
5. Measuring on the device
Cited findings
performance.now()is coarsened: 5 µs when cross-origin isolated, 100 µs otherwise (MDN’s figures, Chromium’s). A 2021 Chromium intent says desktop Safari and Firefox coarsened to 1 ms. — MDN, blink-dev intent (search summary). So time short work in batches (repeat until ≥ 100 ms, divide).- Long Animation Frames (LoAF): a frame is long when “a rendering update is delayed beyond 50 milliseconds”; shipped in Chrome 123; not in Firefox or Safari. The Long Tasks API stays. — Chrome, LoAF
- Remote debugging: iOS Settings > Safari > Advanced > Web Inspector, then a Mac’s Safari Develop menu over a cable (or wireless debugging set up in Xcode). — WebKit, Enabling Web Inspector. Android:
chrome://inspectover USB with USB debugging on. — Chrome, remote debugging
Inferences
- (inference) Time frames by rAF timestamps (intervals), not by a busy loop; the median interval gives the refresh rate (8.3, 16.7 or 33.3 ms) and exposes Low Power Mode. Keep a ring of the last 120 frame times in the dev panel.
Gaps
- No phone was measured for this note; the bench card on the page is for that. Notebookcheck’s scores are for browser suites, not this game’s workloads; the ratio from JetStream is an estimate.
- Safari’s current
performance.now()resolution and its cross-origin-isolated value weren’t confirmed from a WebKit source. - No primary measurement of canvas
putImageDatavs WebGL texture upload at 320×180 on phones was found (the bench card times the CPU side of putImageData + drawImage). - The iOS total-canvas-memory cap has no official number; 384 MB (iOS 15) is one developer’s report.