Outerfar Studios Field note / 01
PHOTONKATANA // Field note

Eight Milliseconds
Acht Millisekunden

For seventeen days our WebXR game died on every cold start of the Meta Quest — silently, with nothing logged. Four theories were wrong, three of our own instruments were lying, and the answer was eight milliseconds wide.

Field note 0125 August 2026WebXR · Trusted Web Activity
Published 25 August 2026 Subject WebXR · Trusted Web Activity · Meta Quest Status Resolved by Meta, 24 August 2026

The symptom

PHOTONKATANA runs on the Meta Quest as a Trusted Web Activity — a thin native wrapper around the same WebXR build that runs in the browser. From the first sideload it had one reproducible defect: after every restart of the headset, the app died a few seconds in. Start it a second time and it ran fine. Open the browser version once beforehand and it ran fine too.

Nothing was logged. No error, no warning, no rejected promise. The immersive session simply stopped existing, and Horizon OS returned to the home environment as if the user had asked it to.

It took seventeen days to find out why. The cause was not in our code, and by the end we had not changed a single line of the game to fix it.

An uncaught NullPointerException inside the browser's media session handling — eight milliseconds after a looping audio element opened its output stream.

Four theories, four dead ends

Every early theory was plausible, and every one of them was wrong. They are worth listing, because the way each was eliminated is the actual method — and because if you are debugging something similar, this is the list you can skip.

Disproven
It's the file size The crash always followed the start of a music track, so the track looked guilty. We re-encoded the same piece from 3.5 MB down to 821 KB — 24 kbps, mono, a factor of 4.3 — and played it three seconds after the first XR frame. It crashed identically, at the same point in the sequence: the trace ended on the play call, before canplaythrough ever fired.
Disproven
The app is starving the compositor A plausible story for a runtime that polices frame delivery: block the main thread, submit no frames, get terminated. So we blocked the main thread for three full seconds with no frames submitted at all, under a complete 3D scene. The session survived without complaint.
Disproven
It's decodeAudioData Decoding on the main thread is a classic cold-start hazard, and our boot decoded several megabytes of clips. We fired twenty-two decodes in a burst during an active session. All of them completed, and nothing died. The 821 KB test had already pointed the same way — that path used a media element and no Web Audio decode at all.
Disproven
It's simply too much load at once The catch-all theory, and the hardest to test honestly. We ran the full scene, the full decode burst and the main-thread block together. Still alive. Whatever killed the app was not a matter of degree.
Held up
It's the media element itself One observation survived everything: after the app was gone, the music kept playing. The process was dead and audio continued from somewhere. That pointed at a media session outliving its page — and a long-lived HTMLAudioElement is exactly what creates one. Short one-shot clips, released after playback, never triggered anything.

The red herring that cost us a week

Our first report to Meta led with OpenGLRenderer setStopped(1), because that was the last meaningful line we could see before the app disappeared. It is real, and it is entirely irrelevant: it is the renderer shutting down at the end of a chain that had already started five seconds earlier. We were reading the bottom of the stack and describing it as the top.

Debugging with no console

The harder half of this problem was not the bug. It was that an immersive Custom Tab on a standalone headset gives you almost nothing to observe with, and three separate instruments lied to us before we noticed.

localStorage loses your last write

Our first tracer wrote progress markers to localStorage and read them back on the next launch. Writes are synchronous to JavaScript but flushed asynchronously by the browser — and a process killed hard never flushes. Every marker we most needed was the one guaranteed to be missing. We moved to batched beacons sent to our own server, plus a build stamp so we could prove which code the device had actually executed.

Timers stop while you are inside VR

A setInterval heartbeat looked like a clean death-clock: last tick before silence marks the moment of death. It marked nothing. During an immersive session the 2D document is hidden, and hidden-document timers are throttled hard. We spent days treating a throttled timer as a crash boundary. The only trustworthy clock in that state is the XR frame loop, so that is what drove telemetry from then on.

The device was running last week's code

For several days the headset ran a build we had already replaced, because the wrapper, the service worker and the CDN each held their own opinion about freshness. Nothing in the app tells you this is happening; the symptoms simply stop matching your source. The fix was unglamorous — no-cache at the edge, no-store on navigation requests, a normalised cache key, and a visible build stamp in the diagnostics so a stale run is obvious on sight.

An await that never returns

Separately, session.updateTargetFrameRate() never settled on the cold path, and it sat as a bare await immediately before our start call. The consequence was a permanently black screen with no error at all. It now runs behind a one-second Promise.race; on device the timeout fires at 1003 ms every time, which is its own small piece of evidence.

The minimal reproduction

Once four theories were dead and the instruments were honest, the fastest remaining move was not more instrumentation. It was subtraction.

We built a separate project — a plain WebXR page, its own manifest and service worker, packaged with Bubblewrap into its own TWA under its own package id, sharing only the Digital Asset Links fingerprint. Then we staged it: a bare scene, a scene with animation, a scene with short clips, a scene with a looping media element. Each stage a button, each stage independently launchable.

Everything ran. The stage with the looping media element died, every time, exactly like the game. Roughly six hundred kilobytes of code now reproduced a bug we had failed to isolate in forty thousand lines. That artefact is what made the rest of the conversation possible: Meta could run it themselves.

The log that answered it

Meta Developer Support asked for the one thing we had not captured — a completely unfiltered capture from all buffers, all priorities, across every process:

adb logcat -b all -v threadtime "*:V"

Headset rebooted immediately before, browser never opened in between, capture started nineteen seconds before launch, nothing filtered. We ran it twice — once on the minimal reproduction, once on the shipping game. Both showed the same chain, and the game's capture carried the full Java stack trace:

E AndroidRuntime: org.chromium.base.JniAndroid$UncaughtExceptionException
  Caused by: java.lang.NullPointerException: Attempt to invoke virtual
      method 'java.lang.String java.lang.Class.getName()' on a null
      object reference
    at android.content.ComponentName.<init>(ComponentName.java:133)
    at android.content.Intent.<init>(Intent.java:7662)
    at org.chromium.content.browser.MediaSessionImpl
        .mediaSessionStateChanged(chromium-OculusBrowser.apk:26)

An Intent constructed with a ComponentName whose class is null. Inside an immersive WebXR Custom Tab, that component appears not to exist. The exception is uncaught, so the process is already doomed at the moment it is thrown — everything visible afterwards is cleanup.

Launcher activity starts
AAudioStreamBuilder_openStream() — this one is fine
AAudioStreamBuilder_openStream() — the looping media element
Uncaught Java exception — eight milliseconds later
ANR: input dispatching timed out
Process killed, destroyTimeout

The line that proved it

The most useful entry in the whole capture is the one that doesn't crash. Seven seconds before the failure, another audio output stream opens and nothing happens. Opening an audio stream is not the trigger.

What separates the two is that the second one belongs to a looping HTMLAudioElement, and that creates a media session. The trigger is not audio. It is the media session state change. That single contrast explains every earlier observation at once: why one-shot clips were safe, why decoded buffers through an AudioContext were safe, why file size never mattered, and why the music went on playing after the app was gone.

The outcome

We submitted both captures with the timeline, the stack trace and the reproduction APK. Meta Developer Support confirmed the defect and, on 24 August 2026, that a browser-side fix is shipping in a coming version of the Oculus Browser — with no change required in our app.

We want to be precise about the shape of that, because it is easy to tell this story badly. This was not a platform ignoring a developer. The support engineer asked for exactly the right capture, read a difficult log carefully, and took a browser bug seriously on the evidence. Our part was to stop guessing and hand over something that could be run. That exchange took days, not months.

What we took from it

  1. Read a log from the start of the chainOur first report described the last line before the silence. The actual cause sat five seconds earlier, in a buffer we were not capturing. If a log has an end that looks like a cause, look for its beginning.
  2. Trust no instrument you have not falsifiedThree of ours were lying: storage that never flushed, timers that were throttled, and a device serving stale code. Each one cost days, and each was invisible from inside the app.
  3. Subtract instead of addingFour theories and a fortnight of instrumentation moved us less than one afternoon of building a minimal reproduction. Six hundred kilobytes isolated what forty thousand lines had hidden.
  4. Give the vendor something they can executeA description invites a discussion. A signed APK that fails in one click, with a capture and a timestamped timeline, invites a fix.
  5. Falsify, don't confirmEvery theory here was killed by a test designed to kill it. The one that survived — the music kept playing after the process died — survived because we kept attacking it and could not.

PHOTONKATANA is a WebXR title for the Meta Quest, running at photonkatana.outerfar.com. The minimal reproduction, both unfiltered captures and the full correspondence are available on request — c.prokop@outerfar.com. If you are shipping WebXR in a Trusted Web Activity and hitting the same wall, get in touch; we would rather you skipped the seventeen days.