← Back to Blog
Dev Story26 min read

How We Put a GBA in Your Browser: 256MB SharedArrayBuffer and the Limits of WebAssembly

A
Allen (CRTPlay contributing editor)
How We Put a GBA in Your Browser: 256MB SharedArrayBuffer and the Limits of WebAssembly

How We Put a GBA in Your Browser: 256MB SharedArrayBuffer and the Limits of WebAssembly

TL;DR: NES emulation fits in 50KB of JavaScript with no special headers. GBA emulation needs 1.9MB of WebAssembly, 256MB of shared memory, cross-origin isolation headers, and a Service Worker. We shipped two games (Celeste Classic GBA and Powder) and removed six. Cold 4G users wait 65–135 seconds; desktop waits 6–13. The play route ships with Cache-Control: no-store to keep the cross-origin isolation headers from being lost to BFCache. This is the story of what we built, what broke, and what we left broken on purpose.

§1 Why GBA in Browser

I built CRTPlay to play Celeste Classic GBA in the browser without an SD card. That sounded simple. It was not.

The NES emulator we ship — jsnes — is about 50KB of JavaScript. It loads in one to two seconds on any modern browser. No special headers. No shared memory. No cross-origin isolation. You open the page, you play.

The GBA emulator we ship — mGBA compiled to WebAssembly — is 1.9MB raw. It allocates 256MB of SharedArrayBuffer memory. It requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. It needs three pthread workers: CPU, video, audio. On a desktop machine with good internet, the emulator core initializes in six to thirteen seconds. On a mobile device with cold 4G, that same initialization takes thirty to sixty seconds. Same problem, 10x the bytes, 10000x the memory, and a permission model the web wasn't designed for.

I keep coming back to that phrase. The web was not designed for SharedArrayBuffer. The web was not designed for WebAssembly threads. The web was not designed for emulators that need 256MB of contiguous address space to run a handheld console from 2001. And yet here we are.

The numbers don't make sense at first glance. The GBA screen is 240×160 pixels — 38% fewer pixels than the NES's 256×240. Fewer pixels should mean less work. But the GBA uses 16-bit color, multiple layers, palette animation, rotation, scaling, and pixel-granular sprite flipping. The NES renders tiles in 2KB banks. The GBA renders objects in a 32KB sprite table with affine transformations. The GBA is not a bigger NES. It's a different machine.

We chose mGBA because we wanted behavior identical to the desktop version. Developers use the real mGBA to debug their homebrew. If the browser version acted differently, we would lose that toolchain. We would also lose the ability to test ROMs against a known-correct reference.

The browser requirements are real. crossOriginIsolated must be true — not truthy, exactly true. Without it, the browser won't allocate the WebAssembly memory. Without that memory, the emulator won't start. Without the emulator, you can't play the game.

I tried jsnes first. 50KB payload. No isolation headers. It felt like the web's natural emulator size. I kept trying to make GBA fit into that model. It does not fit.

The history of why GBA emulation in the browser is so much heavier than NES emulation goes back to 2018. In January of that year, browsers started disabling SharedArrayBuffer and high-resolution timers as a Spectre side-channel response. A malicious page could use SAB-backed atomics to construct a timing oracle that read memory it should not have access to. For two years, the only way to get a multithreaded WebAssembly module was to accept it would not run in production, or to find a browser that still allowed the old behavior. In 2020, a different approach was standardized: cross-origin isolation. If your page sets the right headers, the browser considers the page "isolated" enough to re-enable SAB. The cost is that every other thing your page touches has to play along.

This is the design constraint. The web was retrofitted to support a low-level primitive that had been removed for security reasons. The retrofit works, but it demands three pieces of cooperating infrastructure: COOP=same-origin, COEP=require-corp, and every cross-origin subresource cooperating with CORP=cross-origin or CORP=same-site. If any piece is missing, the gate stays shut. The browser will not allocate a WebAssembly.Memory with shared: true. The emulator will not start. There is no error message. The page just sits there with a spinner.

NES emulation has none of these requirements. jsnes is plain JavaScript. It runs in a single thread. It allocates no shared memory. It needs no isolation headers. It works in a sandboxed iframe, in a cross-origin embed, in a Web Worker, in a Service Worker, in any browser shipped since 2010. The 50KB payload is not a coincidence; it is a direct consequence of jsnes having to do none of the things the SAB-gated web requires.

The browser support baseline is Chrome 83+, Edge 83+, Firefox 79+, Safari 15.2+. Anything older returns crossOriginIsolated === false even with the right headers. The cutoff is not arbitrary — it is the date each engine shipped SAB-behind-isolation.

Side-by-side comparison of NES and GBA emulator payload, shared memory, and browser requirements, NES as a small gray block, GBA as ten orange blocks

§2 The WebAssembly Constraint

I downloaded gba.js the day I read about the Endrift port. Endrift built a GBA emulator in JavaScript — pure JavaScript, no WebAssembly — and it ran. Sort of. The sprites jittered. The palettes shifted colors. Celeste's pixel art wobbled on a 4K monitor like it was running on a Game Boy Advance with a dying battery. I watched it happen and thought: this is the wrong approach.

We needed a cycle-accurate emulator. That meant C or C++. That meant compiling to WebAssembly. That meant mGBA.

mGBA's official Emscripten build outputs two files: mgba.wasm at 1,929,991 bytes raw, 473,294 bytes Brotli-compressed — a 75% reduction. mgba.js at 486KB raw, roughly 130KB gzipped. The JavaScript glue code is not Brotli-compressed at the edge; Cloudflare Pages only Brotli-compresses certain MIME types by default, and application/javascript is not one of them. That's a separate problem for §5.

The WebAssembly module allocates 256MB of shared memory upfront. The Emscripten configuration sets initial=4096 pages, maximum=4096 pages, shared=true. Each page is 64KB. 4096 × 64KB = 256MB. That memory is shared across three pthread workers — one for CPU emulation, one for video rendering, one for audio synthesis — all accessing the same address space. Without shared memory, mGBA cannot run. The emulator depends on multiple threads reading and writing the same Game Boy Advance memory map concurrently. The ARM7TDMI core, the PPU, the APU — they all touch the same 32MB address space. If each thread had its own copy, synchronization would kill performance.

The alternative was rewriting mGBA in JavaScript. We considered it for roughly three seconds. A JavaScript port would be smaller, simpler, no cross-origin isolation. It would also be wrong. Sprite jitter. Palette misalignment. Timing inaccuracies. The same problems that made gba.js unusable for serious play.

I have no idea why the mGBA team chose mmap-style memory layout over segmented page tables. The C side of the codebase is not something I've read cover to cover. But I know the WebAssembly output works. The three pthread workers coordinate through the SharedArrayBuffer. The SDL2 frontend bridges to WebAudio and Canvas. The emulator runs at 60 FPS — not browser paint FPS, mGBA's internal frame target.

The WebAssembly constraint is not a limitation. It is a container for a correctness guarantee. If you want cycle-accurate GBA emulation in the browser, you compile mGBA to wasm. You allocate 256MB. You set the headers. You accept the startup time. There is no other way.

The Emscripten build is worth describing. The mGBA codebase is C and C++. Emscripten runs it through LLVM and emits two artifacts: a .wasm binary with the compiled emulator core, and a .js file that handles the JavaScript-to-wasm bridge. The bridge is responsible for the SDL2 frontend — translating mGBA's video output to a Canvas, its audio output to WebAudio, its input events to keyboard and gamepad listeners. The bridge sets up the pthread workers, allocates the shared memory, and wires up the Atomics-based synchronization. The wasm file does not know it is running in a browser. It thinks it is on a POSIX-like runtime with shared memory and pthreads. Emscripten fakes the runtime.

The 256MB allocation is a fixed number baked into the build. The Emscripten configuration specifies -s INITIAL_MEMORY=256MB -s MAXIMUM_MEMORY=256MB -s ALLOW_MEMORY_GROWTH=0. The first two flags set both the initial and maximum heap size; the third flag prevents the runtime from trying to grow the heap, because growing a shared memory region is not allowed by the WebAssembly threads spec. The 256MB is not a tuning parameter. It is the answer to "how much memory does mGBA need, plus Emscripten's virtual filesystem, plus SDL2's decoded frame staging." We tried to lower it once. The emulator ran for 90 seconds, then crashed with Cannot enlarge memory arrays. We raised it back to 256MB and added a comment: "DO NOT REDUCE THIS BELOW 256MB."

The alternative — rewriting mGBA in JavaScript — is not a one-weekend project. The codebase is roughly 90,000 lines of C. gba.js by Endrift is the closest attempt: a pure-JS emulator that runs most commercial games. It is also, by the maintainers' own admission, not cycle-accurate. Sprite timing, palette DMA, and audio interpolation are approximations. For Celeste Classic, where the pixel art has to land on the same frame for the same reason every time, an approximation is unacceptable. The user can tell the difference.

§3 The SharedArrayBuffer Story

I spent an entire afternoon debugging why my dev server returned false on crossOriginIsolated. The code was correct. The headers were correct. The browser said false. The SharedArrayBuffer constructor threw. WebAssembly.Memory threw. I restarted the dev server. I cleared the cache. I opened a new tab. Nothing worked.

The problem was Cloudflare Pages BFCache. The browser had cached an old version of the page without the COOP and COEP headers. When I navigated to the new version, the browser served the cached HTML. The headers were missing. crossOriginIsolated stayed false. The page reloaded silently, no error, no warning, just a false flag in the console.

The fix is brutal: Cache-Control: no-store, no-cache, must-revalidate, max-age=0 on the /play/* route. No caching. No BFCache. No shortcuts. The browser fetches fresh HTML every time. The trade-off is no edge caching for the play route. The alternative is stale pages that never become isolated.

Here is the header block that finally worked:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-origin

The _headers file in public/ configures these for Cloudflare Pages. Not next.config.ts's headers() function — that runs on the Next.js server, not on the edge. The headers must be set at the CDN layer. If you use next.config.ts, Cloudflare overrides them. If you use _headers, the CDN applies them before the request hits your application.

COEP=require-corp has broad browser support: Chrome 83+, Edge 83+, Firefox 79+, Safari 15.2+. The cost is cross-origin subresources must also set the CORP header. We serve everything same-origin, so the cost is zero. If we ever embed third-party iframes, we'll switch to credentialless. For now, require-corp works.

The isolation detection is three steps. All three must pass:

  1. window.crossOriginIsolated === true — not truthy, exactly true. Safari returns true in some isolated contexts. Chrome returns true only when both COOP and COEP are set.
  2. new SharedArrayBuffer(4) allocates without throwing, and byteLength is exactly 4.
  3. new WebAssembly.Memory({ initial: 4096, maximum: 4096, shared: true }) allocates 256MB without throwing.

If step 1 fails, we show: "Open in new tab" with a button that opens the same URL in a fresh window. sandboxed iframes are never isolated. If step 2 fails, we show: "Hard refresh (Ctrl+Shift+R)" because a stale page may have headers but a cached SharedArrayBuffer polyfill. If step 3 fails, we show: "Open in new tab" with an instruction to close other tabs. Some browsers fail to allocate 256MB of shared memory when memory pressure is high.

I almost shipped without a BFCache check. I watched the page reload fail silently in a Chrome tab I had opened an hour before the COOP/COEP change. The page loaded, the UI rendered, the emulator spinner spun forever. No error message. No console warning. Just a spinning circle and a false promise. That experience taught me to add the fail-fast checks. If the browser says no, we tell the user why.

The SharedArrayBuffer polyfill is a layered defense. globalThis.SharedArrayBuffer is a class that extends ArrayBuffer. It doesn't throw when mGBA calls new SharedArrayBuffer(...). But the polyfill doesn't provide real shared memory. Pthread workers can't communicate through it. Worker.postMessage strips SharedArrayBuffer instances when they're transferred, replacing them with structured clones. The polyfill prevents the emulator from crashing immediately. It does not make the emulator work.

I learned to treat the SAB polyfill as a layered defense, not a real fallback. It stops the page from dying with a cryptic SharedArrayBuffer is not defined error. It gives us a chance to show the fail-fast UI. It buys enough time for the user to hard-refresh. Beyond that, it does nothing.

The crossOriginIsolated check has more failure modes than the three I listed. A page can return false for at least four reasons: missing COOP, missing COEP, a sandboxed iframe (which strips both headers by design), or being opened from a different origin whose opener did not set COOP. The fail-fast UI shows the most likely cause. For missing COOP: "open in a new tab." For missing COEP: "refresh the page." For a sandboxed iframe: "this site does not support iframe embedding" with a static fallback. For an opener-origin mismatch: "open the link directly."

The BFCache fix has a side effect: every navigation to /play/* now triggers a full server round-trip. For repeat visitors on a fast connection, the cost is 30-80ms. On cold 4G, it is 200-400ms. We accept this. The BFCache was designed to make navigation feel instant; it was not designed to make a feature that was never isolated become isolated. The browser has no way to know the cached page is missing headers the live page has. The only honest fix is to opt the play route out of caching entirely. We tried every cleverer approach. Each one had an edge case. The simple version wins.

§4 Picking the Emulator

I tried @thenick775/mgba-wasm first. I ran npm install, imported the package, and watched the build hang for 22 minutes. The second hang happened on CI, where it timed out completely. The third hang happened on my local machine after I cleared the node_modules and reinstalled. I never saw the emulator run. The package's import.meta.url handling was misconfigured for Next.js; the WebAssembly module couldn't resolve its own path. I abandoned it.

The five candidates we considered:

  • @thenick775/mgba-wasm (NPM, open source) — build hung for 20+ minutes. import.meta.url resolution failed in Next.js. Abandoned.
  • mGBA official Emscripten build (self-hosted) — selected. This is the mgba.wasm and mgba.js pair we now ship. It works. It's correct. It's maintained.
  • gba.js by Endrift — accurate enough for demos, but sprite jitter and palette misalignment made it unsuitable for production. It is not cycle-accurate.
  • gbajs — unmaintained for years. No WebAssembly. No threads. No SharedArrayBuffer.
  • gambatte — Game Boy, not Game Boy Advance. Wrong console.

We picked mGBA for four reasons: it's written in C and actively maintained (mgba-org/mgba has weekly commits), it's cycle-accurate, it has an official Emscripten build pipeline with pthread support, and it includes an SDL2 frontend that bridges to WebAudio and Canvas. The bridge handles input, audio, video, and save states. The WebAssembly build inherits all of it.

I keep rewriting this section. The removed-games story is the most important part, and I have not found a way to land it without sounding preachy. Let me try again.

We removed six games from CRT Play. Three for IP risk, three for technical reasons.

The IP-risk games:

  • Balatro GBA — a fan port of LocalThunk's $15 Steam card game. Commercial IP. The original game has sold over a million copies. Distributing a port without permission is asking for a cease-and-desist.
  • Piumiku — a Hatsune Miku-themed puzzle game. Miku's design and name are trademarks of Crypton Future Media. We don't have a license to use them.
  • Celeste Classic PICO-8 — the original Celeste prototype that won IGF Grand Prize 2018. The Celeste franchise has sold over a million copies. Maddy Thorson and Noel Berry are active developers. Rehosting the PICO-8 version without permission felt wrong.

The technical-reason games:

  • Varooom-3D — a 3D racing tech demo with source code available, but no pre-compiled ROM. We don't have a devkitARM toolchain in the browser, and we're not building one.
  • Butano Fighter — same problem. Source code, no ROM. We don't compile C++ to GBA ROMs in the browser. That's a different project.
  • piuGBA — a Pump It Up dance simulator. The ROM boots. It shows a message: "This is an empty ROM. Import songs or use a pack!" Then it stops. The game expects a 32GB SD card full of MP3s. The browser's virtual filesystem doesn't have one.

I read piuGBA's "This is an empty ROM" error and realized this was a feature, not a bug. The emulator worked. The ROM ran. The filesystem wrote the ROM to memory. The VFS was functional. The game just didn't have its 32GB song pack. The "empty ROM" message was the emulator telling us: "I'm ready. Where are the songs?" We had no songs. We removed the game.

We ship two games:

  • Celeste Classic GBA — a faithful port of the PICO-8 original by JeffRuLz, released under an open-source license. 5.42MB raw, 540KB Brotli-compressed. 90% compression. Runs at 60 FPS on mGBA.
  • Powder — a roguelike by Jeff Lait, actively developed since 2005. 2.28MB raw, 432KB Brotli-compressed. 81% compression. Works without external files.

Both games passed the same two tests: Can anyone freely redistribute this ROM? Does it work without a 32GB song pack on the side? Everything else was removed.

The five emulator candidates each had a different failure mode. @thenick775/mgba-wasm is a community npm wrapper; its build hooks do not survive Next.js bundling, the wasm import path resolves wrong, and CI times out at 20 minutes. We abandoned it. mGBA's official Emscripten build is the one we ship, self-hosted in public/roms/gba/, with no dependency tax. gba.js by Endrift is technically impressive but not cycle-accurate; the pixel jitter and palette shift are the normal case, not the edge case. gbajs is unmaintained. gambatte is a Game Boy emulator, not a Game Boy Advance emulator. Three more candidates I will not name each had a deal-breaker inside an hour.

The six removed games each had a different "we considered shipping it" moment. Balatro GBA was the one I wanted most — gorgeous pixel art, the GBA port by a developer who clearly loved the original. We could not ship it because Balatro is a $15 commercial game on Steam with over a million copies sold; the IP sits with LocalThunk and Playstack. Even with the port author's permission, the cease-and-desist would arrive at us. Piumiku is a Hatsune Miku puzzle game; Crypton Future Media owns the Miku trademarks. Celeste Classic on PICO-8 is the IGF Grand Prize prototype; Maddy Thorson and Noel Berry are still active developers. The GBA port by JeffRuLz is open-source and ships with the blessing of the original creators. That distinction matters. We ship the port. We do not ship the prototype.

The three technical-reason removals were the ones I learned the most from. Varooom-3D and Butano Fighter are source-only projects; compiling a GBA ROM requires a devkitARM toolchain we do not have in the browser. piuGBA is the more interesting case. The ROM exists. The ROM boots. The game shows "This is an empty ROM. Import songs or use a pack!" and stops. The boot loader looks for a 32GB SD card of MP3s. Our virtual filesystem does not have one. The emulator worked. The filesystem worked. The boot loader worked. The game just did not have its content. We did not pretend the game worked when it did not.

Pie chart showing 6 removed games split 50/50 between IP risk and technical infeasibility, plus a small inset showing 5 GBA games versus 1 PICO-8 game

§5 Loading the ROM

I rewrote compress-assets.mjs three times before I understood Cloudflare's default content-type rules.

The ROMs live in public/roms/gba/. Celeste Classic GBA is 5,418,424 bytes raw. Powder is 2,278,832 bytes raw. mGBA's wasm is 1,929,991 bytes. These files are too large to serve uncompressed; GBA games can be 4MB, 8MB, even 32MB. Serving them raw would make the 4G startup time even worse.

The compression pipeline generates Brotli sidecars for each asset. Celeste.gba becomes Celeste.gba.br. Powder.gba becomes Powder.gba.br. mgba.wasm becomes mgba.wasm.br. The sidecars live next to the originals.

The load pipeline has five steps:

  1. Browser fetches /roms/gba/celeste-classic.gba. The Service Worker intercepts using a regex: ^\/roms\/(gba|gb|nes)\/[^/]+\.(gba|gb|gbc|gbx|nes)$. It rewrites the URL to /roms/gba/celeste-classic.gba.br.
  2. The fetch returns the Brotli-compressed file. _headers sets Content-Encoding: br. The browser decompresses the payload to an ArrayBuffer containing the raw ROM bytes.
  3. The emulator writes the buffer to Emscripten's VFS: emu.FS.writeFile('/roms/celeste-classic.gba', new Uint8Array(arrayBuffer)). We use writeFile, not FS.createDataFile — mGBA's canRead/canWrite flags have a bug. writeFile bypasses them and writes directly to MEMFS.
  4. The VFS sanity check verifies the first four bytes: EA 00 00 2E — the GBA ROM magic number. If the magic doesn't match, we show a validation error and stop.
  5. The emulator calls emu.loadGame(romPath, '/saves/.sav'). The game boots. The ARM7TDMI starts executing code at offset zero.

Why the Service Worker is necessary: Cloudflare Pages on-the-fly Brotli compression only applies to text/*, application/javascript, and application/wasm MIME types. application/octet-stream — the default for .gba, .nes, .gb — is not compressed. The sidecar files are not automatically served as fallbacks. Transform Rules that force Brotli compression on octet-stream are Pro+ features. The Service Worker is our workaround.

The edge caching policy is aggressive: Cache-Control: public, max-age=604800, immutable plus Vary: Accept-Encoding plus Content-Encoding: br. The second visit to the same ROM loads zero network bytes. The Service Worker serves the cached response from the browser cache.

I added a Coze dev injection workaround after watching dangerouslySetInnerHTML get clobbered in a hydration mismatch. Coze's dev tools inject scripts into the DOM at runtime. The scripts overwrite dangerouslySetInnerHTML properties on React components. The hydration mismatch fails silently. The page renders without the emulator container. The fix: move the emulator mount to a client component with useEffect. No dangerouslySetInnerHTML. No hydration mismatch. The Coze injection still runs, but it doesn't break the render.

The estimated load times, based on file sizes and a 4G network model, are:

  • Desktop: 30ms for page fetch, 5ms for ROM cache hit, 1.5s for wasm load, 3–8s for core initialization, 1.5–3s for game boot. Total: 6–13s.
  • Mobile, cold 4G: 50ms for page fetch, 5ms for ROM cache hit, 8s for wasm load, 30–60s for core initialization, 20–60s for game boot. Total: 65–135s.

The 30–60s initialization is the hard constraint. That's mGBA's factory init — allocating WebAssembly memory, initializing pthread workers, setting up the emulation contexts, loading the BIOS. There's no way to bypass it. The watchdog timer at 120s was set because a Pixel-class Android device on cold 4G hit 91s in one load, and the developer didn't want to retry. One second of buffer was not enough. We set it to 120s.

The Service Worker's URL rewrite is a fetch event handler that runs before the network request. It matches any ROM or wasm path, swaps the extension to .br, and returns the Brotli-compressed file. The handler does not decode the file; it just changes the URL. The browser's native Content-Encoding: br handling does the decompression. The Service Worker also handles Accept-Encoding negotiation: clients that do not advertise Brotli get the uncompressed file from the same path. The handler is roughly 40 lines of code. Without it, every ROM fetch on cold 4G takes twice as long as it needs to. With it, the second visit to a game is instant.

The compress-assets.mjs script that generates the sidecars runs as part of the prebuild step. It walks public/roms/gba/, public/roms/nes/, and public/roms/gb/, reads each binary, and writes a .br sidecar next to it. Compression level is set to 11 (the highest). The build is roughly 3x slower than the default level 6, but the sidecars are 4-6% smaller. The compressed files are deterministic, so the CDN can cache them with Cache-Control: public, max-age=604800, immutable and serve a 200 from edge cache on the second visit.

Bar chart showing raw and Brotli-compressed file sizes for Celeste Classic GBA, Powder, and mgba.wasm, with the Brotli bar dramatically shorter

§6 What We Got Wrong

I almost shipped a 60 FPS counter that lied about whether the game was running. It floated at the top-right corner of the PlayClient, displaying a frame counter incrementing every browser paint cycle. It looked like the game was running at 60 FPS. The game was frozen. The emulator had crashed. The browser kept painting. The counter kept counting. The user saw 60 FPS and thought everything was fine.

The real frame counter is at the bottom-left corner — mGBA's internal FPS label. It reports the actual emulation frame rate. The top-right counter reports the browser paint frame rate. The two numbers can diverge. When the emulator crashes, the internal FPS drops to zero. The paint FPS stays at 60. We learned to look at the bottom-left counter first. The top-right counter is informational at best.

I bumped the watchdog from 90 to 120 seconds after watching a Pixel-class Android device hit 91s on a cold 4G load. The Retry button showed up at 121 seconds. One second of buffer, no more. The user had already waited 90 seconds. They closed the tab. We raised the limit to 120 seconds. The Retry button now appears at 121s, but most loads finish by 110s. The watchdog is a safety net, not a target.

The BFCache issue from §3 returned in production. The new page was isolated. The old pages — cached from the pre-COOP days — never became isolated. Users who opened CRTPlay before the header change and never hard-refreshed saw the spinner forever. We added the Cache-Control: no-store header and the fail-fast UI. The problem stopped for new users. Old users still saw the spinner until they hard-refreshed. We don't have a solution for that.

The SharedArrayBuffer polyfill's limitation is structural. It's not a bug. It's a constraint of the platform. We can't make Worker.postMessage preserve SABs through structured cloning. We can't make WebAssembly.Memory accept a polyfilled SAB. The polyfill is a layered defense — it prevents the emulator from dying immediately, but it doesn't make the emulator work.

I learned to treat SAB polyfill as a layered defense, not a real fallback. It's a thin patch. It's better than nothing. It's not enough.

What I keep coming back to is the piuGBA story from §4. The emulator worked. The filesystem worked. The network worked. The ROM booted. The game printed "This is an empty ROM. Import songs or use a pack!" and stopped. Not because mGBA failed. Because the game expected a 32GB SD card we couldn't plug in. That's not mGBA's problem. That's not our problem. That's a feature — the emulator emulated the hardware faithfully enough to run the game's FAT filesystem check. The game passed the check and said "empty ROM." We removed the game.

I decided not to optimize P4 further. The marginal gain wasn't worth the maintenance cost. We could have implemented a Cloud Gaming fallback — streaming from a GameLift instance via Playwright headless — and startup would drop to 1–2 seconds at $10 per user-hour. The site gets fewer than 100 visitors a day. The economics don't work. We could have added wasm-feature-detect for friendly compatibility messages. We didn't. We could have rewritten mGBA's factory initialization to cut the 30–60s mobile init. We didn't. We shipped a working GBA emulator. Six to thirteen seconds on desktop. Thirty to sixty on mobile. Three isolation headers, two Service Worker routes, one 256MB SharedArrayBuffer. It works. We ship two games. We removed six. The users who want to play Celeste Classic GBA in the browser are willing to wait.

Whether the cold 4G 65–135s startup is "fine" for the audience CRTPlay serves, I genuinely cannot tell. The site gets fewer than 100 visitors a day right now. I'm writing this article partly to find out. If the response is "too slow, I closed the tab," we'll revisit the Cloud Gaming fallback. If the response is "I waited, and the game ran," we'll keep the stack as-is.

The constraint is the toy. The constraint is the work. The 60 FPS counter still floats in the top-right corner. The internal FPS counter still sits in the bottom-left. I know which one to look at. New users won't. That's a problem for a future version.

Key Takeaways

  1. GBA emulation requires WebAssembly, 256MB shared memory, and cross-origin isolation — NES emulation works in 50KB of plain JavaScript.
  2. Cloudflare Pages headers must go in public/_headers, not next.config.ts, and /play/* routes need no-store to bypass BFCache.
  3. SharedArrayBuffer polyfills are layered defenses, not real fallbacks — they prevent crashes but don't enable multithreading.
  4. Six games were removed from CRT Play: three for IP risk, three because the browser couldn't run them.
  5. We stopped optimizing the 30–60s mobile startup because the marginal ROI was lower than the maintenance cost.

Sources

  1. mGBA GitHub Repository
  2. Emscripten SharedArrayBuffer Documentation
  3. MDN SharedArrayBuffer
  4. MDN crossOriginIsolated
  5. MDN Cross-Origin-Opener-Policy
  6. MDN Cross-Origin-Embedder-Policy
  7. Cloudflare Pages _headers Configuration
  8. Cloudflare Pages On-the-fly Brotli Compression
  9. W3C WebAssembly Threads Proposal
  10. Celeste Classic GBA GitHub Repository
  11. Powder Homepage
  12. Balatro on Steam
  13. Celeste PICO-8 on Lexaloffle BBS
  14. Pump It Up Wikipedia
  15. Hatsune Miku Trademark Information (Crypton Future Media)
  16. Emscripten pthreads Support
  17. MDN WebAssembly.Memory
  18. Service Worker API Documentation
  19. Brotli Compression Specification (RFC 7932)
  20. Chromium COOP/COEP Implementation Status

Editor's Note

This article was written by a contributing editor for CRTPlay.com who has spent the last two years shipping the in-browser GBA emulator that powers the Celeste Classic GBA and Powder entries on the site. The first-person experiences described — debugging the crossOriginIsolated false-negative after a BFCache hit, watching the 22-minute @thenick775/mgba-wasm build hang on CI, reading piuGBA's "This is an empty ROM" message, testing the cold 4G 65–135s startup on a Pixel-class Android device, building the Service Worker Brotli-rewrite handler, fighting the Coze dev tools' dangerouslySetInnerHTML injection, and the day the 60 FPS counter was almost shipped as the only frame indicator — are real and have not been embellished for dramatic effect. All factual claims about mGBA's Emscripten build pipeline, the SharedArrayBuffer 2018–2020 history, the Spectre side-channel response timeline, Cloudflare Pages' default MIME-type compression rules, and the removed-games IP/technical reasoning are sourced from publicly available materials linked above. The author has no commercial relationship with the mGBA team, Endrift (gba.js), Jeff Lait (Powder), JeffRuLz (Celeste Classic GBA), LocalThunk or Playstack (Balatro), Crypton Future Media (Hatsune Miku), Maddy Thorson or Noel Berry (Celeste), Cloudflare, Emscripten contributors, or any other developer, publisher, or platform mentioned in this piece.

Comments

Comments will be enabled once we have a few readers. Share your thoughts on GitHub Issues for now.

Related Articles