Measure the RN → WebView pose-bridge latency #16

Open
opened 2026-08-11 15:55:43 +00:00 by lars · 2 comments
Owner

Question

Both #3 and #6 independently landed on the same load-bearing unknown: nobody has published the one-way injectJavaScript pose-channel latency at 30–60Hz, and no splat frame rate inside WKWebView exists in the literature either. Two of the four proposed gates (#11) and the whole architecture bet in #14 rest on it. It is also cheap to settle — an afternoon, no splat required — which is why it is a task rather than a research ticket.

Build the smallest possible probe:

  • Bare Expo app, react-native-webview, a page that does nothing but echo.
  • RN stamps performance.now() into a pose object and pushes it via injectJavaScript at a fixed rate; the page echoes it back via window.ReactNativeWebView.postMessage on the next requestAnimationFrame; RN logs the delta.
  • Sweep the push rate (10 / 30 / 60Hz) and report median and p95, not a mean — the tail is what reads as lag.
  • Repeat with a splat loaded and rendering, to separate bridge cost from render cost.
  • Compare against pushing only sparse poses and interpolating inside the WebView (what #3 recommends), so the spec's architecture is chosen on measurement.
  • Run on the actual test iPhone, not a simulator.

Done when the numbers are posted here. They become the latency gate in #11 and the pose-channel design in #14.


Part of the wayfinder map #1.

## Question Both #3 and #6 independently landed on the same load-bearing unknown: **nobody has published the one-way `injectJavaScript` pose-channel latency at 30–60Hz**, and no splat frame rate inside WKWebView exists in the literature either. Two of the four proposed gates (#11) and the whole architecture bet in #14 rest on it. It is also cheap to settle — an afternoon, no splat required — which is why it is a task rather than a research ticket. Build the smallest possible probe: - Bare Expo app, `react-native-webview`, a page that does nothing but echo. - RN stamps `performance.now()` into a pose object and pushes it via `injectJavaScript` at a fixed rate; the page echoes it back via `window.ReactNativeWebView.postMessage` on the next `requestAnimationFrame`; RN logs the delta. - Sweep the push rate (10 / 30 / 60Hz) and report **median and p95**, not a mean — the tail is what reads as lag. - Repeat with a splat loaded and rendering, to separate bridge cost from render cost. - Compare against pushing only sparse poses and interpolating inside the WebView (what #3 recommends), so the spec's architecture is chosen on measurement. - Run on the actual test iPhone, not a simulator. Done when the numbers are posted here. They become the latency gate in #11 and the pose-channel design in #14. --- Part of the wayfinder map #1.
lars added this to the Wayfinder: RN prototyping spec milestone 2026-08-11 15:55:43 +00:00
lars added the wayfinder:task label 2026-08-11 15:55:43 +00:00
lars added a new dependency 2026-08-11 15:55:43 +00:00
lars added a new dependency 2026-08-11 15:55:43 +00:00
Author
Owner

Baseline and method, from #3's second addendum — this ticket is now partly answered and better specified.

Published Android numbers to measure against (discussion #3669): 31 ms postMessage round trip at 100 chars, with a ~10 ms floor on the RN→native command alone that is independent of payload size. localhost HTTP measured 5.9 ms for the same payload — a ~5× win in exactly the pose regime.

No published iOS figure exists, which is precisely why this ticket is worth an afternoon.

Add these arms to the probe:

  1. injectJavaScript per-frame at 10/30/60Hz — the baseline, expected to be the loser.
  2. Sparse keyframes + in-WebView extrapolation — push pose plus velocity at 10–20Hz, integrate forward in requestAnimationFrame. Standard AR practice (late-stage reprojection); converts channel cost from per-frame to per-keyframe.
  3. localhost WebSocket / HTTP — worth one measurement given the 5× Android result. Caveat: #2798 reports WebSockets failing inside react-native-webview; verify before designing around it.
  4. DeviceOrientationEvent inside the WebView — zero-hop orientation, see #17.

Method notes:

  • Use performance.now() with one measured offset, not Date.now().
  • Report median and p95, and run on the physical test iPhone.
  • Inject a resident window.__setPose handler once via injectedJavaScriptBeforeContentLoaded; don't re-eval a source string per frame, and don't route pose through React state.
  • postMessage coerces with String(data)JSON.stringify explicitly.
  • Make the handler idempotent: injectJavaScript has a double-dispatch history.
  • There is no backpressure on this channel — coalesce to a latest-only slot rather than queueing.

Existing harness to port (targets the old architecture, so update it first): jamonholmgren/WebViewPing.

**Baseline and method, from #3's second addendum — this ticket is now partly answered and better specified.** Published Android numbers to measure against ([discussion #3669](https://github.com/react-native-webview/react-native-webview/discussions/3669)): **31 ms** postMessage round trip at 100 chars, with a **~10 ms floor on the RN→native command alone** that is independent of payload size. `localhost` HTTP measured **5.9 ms** for the same payload — a ~5× win in exactly the pose regime. **No published iOS figure exists**, which is precisely why this ticket is worth an afternoon. Add these arms to the probe: 1. **`injectJavaScript` per-frame** at 10/30/60Hz — the baseline, expected to be the loser. 2. **Sparse keyframes + in-WebView extrapolation** — push pose plus velocity at 10–20Hz, integrate forward in `requestAnimationFrame`. Standard AR practice (late-stage reprojection); converts channel cost from per-frame to per-keyframe. 3. **localhost WebSocket / HTTP** — worth one measurement given the 5× Android result. Caveat: [#2798](https://github.com/react-native-webview/react-native-webview/issues/2798) reports WebSockets failing inside `react-native-webview`; verify before designing around it. 4. **`DeviceOrientationEvent` inside the WebView** — zero-hop orientation, see #17. Method notes: - Use `performance.now()` with one measured offset, not `Date.now()`. - Report **median and p95**, and run on the physical test iPhone. - Inject a resident `window.__setPose` handler once via `injectedJavaScriptBeforeContentLoaded`; don't re-eval a source string per frame, and don't route pose through React state. - `postMessage` coerces with `String(data)` — `JSON.stringify` explicitly. - Make the handler **idempotent**: `injectJavaScript` has a double-dispatch history. - There is **no backpressure** on this channel — coalesce to a latest-only slot rather than queueing. Existing harness to port (targets the old architecture, so update it first): [jamonholmgren/WebViewPing](https://github.com/jamonholmgren/WebViewPing).
Author
Owner

Correction to my earlier note on this ticket: the 10ms floor is Android-only.

Measured iOS figures do exist (see #3 addendum 3): evaluateJavaScript round trip 0.39ms and WKScriptMessageHandler 0.63ms on iOS 9 / iPad Air 2, so ~0.2–1.5ms one-way extrapolated to current silicon. Per-frame injection at 60Hz on iOS is plausibly fine. The ~10ms floor and the 5× localhost win are the Android postMessage prop path.

So this probe's purpose shifts: less "is the bridge viable" and more "confirm it on modern hardware, and find what actually dominates". Revised arms, in priority order:

  1. evaluateJavaScript per-frame at 60Hz on the real iPhone — expected sub-millisecond. The 2015 benchmarks are the only published data and they predate modern silicon by a decade.
  2. Native-direct injection vs the RN JS path. Mozilla's WebXR Viewer holds ARSession and WKWebView in the same native module and injects straight from session(_:didUpdate:) with completionHandler: nil. react-native-webview always passes a non-nil block, paying WebKit's return-path cost (IPC result serialization + a JSContext). Measure both — if the delta is real it argues for a small native module rather than the RN bridge.
  3. rAF-to-visible delta, which is likely the dominant term. Note rAF is hard-capped at 60Hz in WKWebView (WebKit 294338), so don't send faster and don't expect 120Hz on ProMotion.
  4. Forward-prediction check — extrapolate ~25ms ahead in the WebView and see whether it visibly compensates. There is no late-warp hook in WKWebView, so this is the only latency compensation available.
  5. Deprioritize the localhost-server arm — its 5× win was Android-specific and it carries a server lifecycle plus ATS caveats.

Method: performance.now() with a measured offset against ARFrame.timestamp (mach uptime); report median and p95; physical device only. Do not put pose on the web→RN direction — Fabric's onMessage is gated on the main run loop hitting BeforeWaiting, with no coalescing and no backpressure.

**Correction to my earlier note on this ticket: the 10ms floor is Android-only.** Measured iOS figures do exist (see #3 addendum 3): `evaluateJavaScript` round trip **0.39ms** and `WKScriptMessageHandler` **0.63ms** on iOS 9 / iPad Air 2, so **~0.2–1.5ms one-way** extrapolated to current silicon. Per-frame injection at 60Hz on iOS is plausibly fine. The ~10ms floor and the 5× localhost win are the **Android `postMessage` prop path**. So this probe's purpose shifts: less "is the bridge viable" and more "confirm it on modern hardware, and find what actually dominates". Revised arms, in priority order: 1. **`evaluateJavaScript` per-frame at 60Hz on the real iPhone** — expected sub-millisecond. The 2015 benchmarks are the only published data and they predate modern silicon by a decade. 2. **Native-direct injection vs the RN JS path.** Mozilla's WebXR Viewer holds `ARSession` and `WKWebView` in the same native module and injects straight from `session(_:didUpdate:)` with **`completionHandler: nil`**. `react-native-webview` always passes a non-nil block, paying WebKit's return-path cost (IPC result serialization + a JSContext). Measure both — if the delta is real it argues for a small native module rather than the RN bridge. 3. **rAF-to-visible delta**, which is likely the dominant term. Note `rAF` is **hard-capped at 60Hz** in WKWebView ([WebKit 294338](https://bugs.webkit.org/show_bug.cgi?id=294338)), so don't send faster and don't expect 120Hz on ProMotion. 4. **Forward-prediction check** — extrapolate ~25ms ahead in the WebView and see whether it visibly compensates. There is **no late-warp hook** in WKWebView, so this is the only latency compensation available. 5. Deprioritize the localhost-server arm — its 5× win was Android-specific and it carries a server lifecycle plus ATS caveats. Method: `performance.now()` with a measured offset against `ARFrame.timestamp` (mach uptime); report median and p95; physical device only. Do **not** put pose on the web→RN direction — Fabric's `onMessage` is gated on the main run loop hitting `BeforeWaiting`, with no coalescing and no backpressure.
Sign in to join this conversation.