
Auditing Expo Exact's Runtime Boundaries on Web, macOS, iPhone, and Linux
On this page
Introduction — One Codebase, Four Targets, and a Lot of Unstated Runtime Boundaries
The pitch sells a conference talk: one TypeScript app, compiled for web, macOS, iPhone, and Linux. If it holds, three build pipelines collapse into one and the platform glue TypeScript teams spend their first quarter writing stops existing. This post audits that claim where it actually matters — the runtime boundaries Expo Exact has to cross between web, macOS, iPhone, and Linux — and gives you the tooling to test those boundaries on a real build rather than on a headline.
I want the pitch to be true. I also want to know which parts are true, because "runs on four targets" and "behaves the same on four targets" are different claims with wildly different costs behind them. The first is a build-graph property you can demonstrate in an afternoon. The second needs a capability matrix, a divergence harness, and several weeks of honest measurement.
What follows is an audit plan: a working model of where a universal runtime actually has seams, plus code you can run against any candidate, this one included, once you have a build in front of you. The harness is worth more than a verdict I can't earn.
What the seed report claims, in plain terms
My material is one secondary item: a news aggregator entry attributed to news.lavx.hu, dated 2026-11-10, headlined as Expo releasing "Exact," described as a runtime that builds one app for web, Mac, iPhone, and Linux. The snippet is headline only. No SDK number, no supported platform matrix, no packaging story, no licensing detail, no link to a first-party announcement.
So the confirmed fact is narrow, and I want to state it carefully: a secondary report says Expo has released a runtime called Exact targeting those four platforms. Everything past that sentence — how it renders, which native modules it exposes, how Linux builds are distributed, whether it's open source — isn't in my source material.
What this audit does and does not verify
What it does: give you a repeatable method for finding the places where a four-target runtime diverges, ranked by how much production pain each divergence predicts. The harness runs today. The Node control output below is real output from my machine.
What it doesn't: confirm Exact's architecture, benchmark it, or tell you whether it's good. I have not built an Exact app. Anything here about Exact specifically that isn't "a report says X" is marked as inference, and in several places I write unknown instead of guessing.
Treat every architectural claim about Exact in this post as inference layered on one headline. The transferable content is the audit method, not the verdict.
Separating the Announcement From the Architecture
The four targets as stated: web, macOS, iPhone, Linux
That's an odd set, and the oddness is a hint about the architecture. Web and macOS/Linux are desktop-ish; iPhone is a locked-down mobile target with App Store review; Linux is a packaging zoo with no single distribution format. Covering all four from one source tree forces one of two bets:
- One rendering engine, many hosts. The runtime ships its own layout and paint implementation and blits into a native surface on each platform, with the web target emitting to canvas. Maximum behavioral consistency, minimum native-UI fidelity.
- One component model, many renderers. The runtime maps its component tree onto platform-native views — DOM nodes on web, AppKit views on macOS, UIKit views on iPhone, GTK or a bundled compositor on Linux. Maximum platform feel, maximum divergence.
The report doesn't say which. That one unknown determines most of the answers below, which is why I'm flagging it here rather than burying it.
Facts I am treating as confirmed vs. claims I am treating as inference
| Statement | Status | Basis |
|---|---|---|
| A runtime called Exact has been reported as released by Expo | Confirmed as a report, not as a first-party fact I verified | Single secondary item, dated 2026-11-10 |
| Targets are web, macOS, iPhone, Linux | Confirmed as stated in the report | Headline text |
| It accepts TypeScript source | Inference | The framing is "TypeScript app"; no API surface is quoted |
| Rendering approach is canvas vs. native views | Unknown | Not stated anywhere in the source |
| Native module coverage beyond what Expo already ships | Unknown | Not stated |
| Linux distribution format | Unknown | Not stated |
| Licensing and source availability | Unknown | Not stated |
The table is short on purpose. When the source is one headline, the honest table is a short table.
Open questions that change the whole evaluation (Expo SDK version, native module coverage, licensing, Linux packaging model)
Four questions, in the order I'd ask them:
Which Expo SDK does this track? A runtime shipping alongside a specific SDK inherits that SDK's deprecation schedule, its React Native version, and the module ecosystem as it stood that day. "Exact runs on Linux" means very little if Linux lags iOS by two SDK cycles.
How much of the existing native-module surface actually resolves? Expo's value has always been the module layer — camera, filesystem, notifications, secure store, haptics. If Exact is a rendering runtime with a partial module story, the modules become your problem, and they're the expensive part.
What's the license, and what happens if the runtime stalls? A universal runtime is a bet on one vendor maintaining four backends. The alternatives spread that risk across a community you can fork. Engineers should raise this in the first meeting, not the third.
How does a Linux artifact reach users? .deb, .rpm, AppImage, Flatpak, Snap, or a tarball with a shell script. Each implies different sandboxing, different update channels, and different library-version assumptions. Packaging sets your support burden more than the rendering engine does.
A Working Model for Auditing Runtime Boundaries
Four boundaries worth auditing: render surface, native API surface, lifecycle and process model, build artifact graph
A cross-platform runtime is four contracts wearing a trench coat. Each passes or fails on its own.
Render surface. What the runtime draws into and what it hands to the host. Divergence shows up as text that wraps one word earlier on Linux, scroll that decelerates differently on web, focus rings that exist on one platform and not another.
Native API surface. Which JS calls resolve to real OS functionality. Worse than rendering divergence because it fails silently — a stubbed camera module returning undefined reads as a user error in your logs.
Lifecycle and process model. What happens on background, on memory reclaim, on window close. iOS suspends aggressively, Linux desktops barely at all, browsers freeze tabs. Unify the API without the semantics and you unify your tests, not your bugs.
Build artifact graph. One source tree, N signed artifacts. This is the boundary vendors promise to collapse and the one that least collapses, because codesigning and notarization aren't software problems you can abstract away — they're contractual obligations to Apple and to each Linux distribution.
The test matrix: targets as columns, capability classes as rows
The matrix is the deliverable. Columns are targets, rows are capability classes, cells are ok, unsupported, stubbed, permission-denied, error, or untested.
| Capability class | web | macOS | iPhone | Linux |
|---|---|---|---|---|
| Layout & text metrics | ? | ? | ? | ? |
| Scroll & gesture physics | ? | ? | ? | ? |
| Focus & keyboard nav | ? | ? | ? | ? |
| Accessibility tree | ? | ? | ? | ? |
| Filesystem | ? | ? | ? | ? |
| Camera / media | ? | ? | ? | ? |
| Notifications | ? | ? | ? | ? |
| Secure storage | ? | ? | ? | ? |
| Background lifecycle | ? | ? | ? | ? |
A marketing page is a single row that says ok four times. The audit's job is to fill in nine rows and find the cells that disagree.
Why 'runs on four platforms' is a weaker claim than 'behaves the same on four platforms'
"Runs" is falsifiable by a screenshot. "Behaves the same" is falsifiable only by a test that names a user-visible invariant and then runs in four places.
Here's the asymmetry that matters commercially: a runtime can be entirely honest and still wreck your schedule, because it degrades exactly where your product is most platform-specific. Photo upload, push notifications, keychain-backed login — those aren't edge cases, they're the feature set of nearly every app.
My position, stated plainly: shared source across four targets is achievable today with tools you already have, and shared behavior is the open research problem. Judge a universal runtime by the capability matrix it produces, not by the platform count in its README.
The Rendering Boundary: Layout, Text Metrics, and Native Views
One component tree, four different layout and paint backends
Whatever the runtime, your component tree gets translated into four layout engines. Four engines mean four implementations of font fallback, four integer-rounding policies for pixel positions, and four answers to "what is the line height of 16px system-ui at 1.5?"
None of that is a bug. It's the normal cost of delegating layout to four hosts. It becomes a bug when your test suite asserts on a pixel value that only holds on one of them.
Web: DOM output vs. canvas vs. native-view bridging
The web target is where you get the clearest signal about the architecture, and reading it takes about five minutes.
- If the web target emits DOM, you get real text selection, real find-in-page, real screen reader support, and a payload the browser can render incrementally. You also inherit every browser CSS divergence.
- If the web target emits into a canvas, you get near-perfect parity with the native targets and lose text selection, find-in-page, browser zoom semantics, and most of the accessibility tree unless it's explicitly re-exposed.
- If it bridges to native views, you get a hybrid: good semantics for the elements that map, and a JS bridge hop per property update.
Check this before anything else:
## Open the web build in a browser, then:
## 1. Select a paragraph with the mouse. If nothing highlights, you are on canvas.
## 2. Ctrl/Cmd+F for a word you can plainly see. If it does not match, canvas.
## 3. Inspect the rendered node. A <canvas> or a single root <div> is the tell.
If selection and find-in-page both work, the web target is DOM-based and your accessibility story is mostly free. If neither works, budget for accessibility remediation you didn't plan for.
macOS and iPhone: AppKit and UIKit surface mapping
macOS and iPhone share a lot of DNA and almost none of their interaction model. UIKit assumes touch, a single window, and a device that suspends. AppKit assumes a pointer, many windows, a menu bar, and a process that lives for days.
What breaks in that gap:
- Menu bar. A macOS app without a proper menu bar isn't a macOS app. If the runtime generates one for you, check whether shortcuts are editable and whether the standard Edit menu wiring (Cut/Copy/Paste/Undo) routes into your text inputs.
- Window lifecycle. Closing the last window on macOS conventionally doesn't quit the app. An iOS-style runtime that quits anyway gets noticed.
- Hover states. There's no hover on iPhone. If your component library treats hover as a first-class signal, half your affordances vanish on one target and are mandatory on another.
- Safe areas. iPhone has them, macOS doesn't. A runtime that applies safe-area insets uniformly produces a macOS layout with mysterious padding.
Linux: which windowing and compositor assumptions are being made
Linux is the target I'd probe hardest, because "Linux" isn't a platform, it's a family. The questions to ask:
- Wayland or X11, or both? A runtime that only works under XWayland will function on most desktops today and feel wrong on HiDPI and multi-monitor setups.
- Which compositor assumptions leak? Fractional scaling is the classic failure: 125% and 150% are common on Linux laptops and produce blurry or misaligned output when a toolkit assumes integer scales.
- Which webview or toolkit is bundled? A system webview (WebKitGTK) ties rendering to the user's distro version. A bundled engine means a 100MB+ artifact.
## On a Linux box, before judging the app on visual quality:
echo "XDG_SESSION_TYPE=$XDG_SESSION_TYPE" # wayland or x11
echo "GDK_SCALE=$GDK_SCALE GDK_DPI_SCALE=$GDK_DPI_SCALE"
## Then set fractional scaling in your desktop settings and restart the app.
## Misaligned 1px borders and blurry text at 125% are the expected failure.
Divergence checklist — text metrics, scroll physics, focus rings, hit slop, accessibility tree exposure
Five checks, all cheap, all predictive.
| Check | How to test | What divergence looks like |
|---|---|---|
| Text metrics | Render a fixed string at a fixed font, measure getBoundingClientRect().width | Width differs by >1px per 100px; line wrapping flips |
| Scroll physics | Flick-scroll a long list; compare deceleration and overscroll | Instant stop on web, rubber-band on iOS, no momentum on Linux |
| Focus rings | Tab through a form | Ring appears on web, absent on iOS, wrong color on Linux |
| Hit slop | Tap a 24px icon with a finger | Works on iPhone, unreliable on web when emulated, fine with a mouse on desktop |
| A11y tree | Inspect with platform tooling | Interactive divs with no accessible name on any target |
That last row is the one I'd escalate. Accessibility exposure is the capability most likely to be silently missing, because a canvas renderer produces an app that looks perfect and reads as an empty rectangle to a screen reader. On macOS and iPhone, verify with the system accessibility inspector rather than your own JS.
The Native Interop Boundary: JS-to-Native Calls and Silent Stubs
The JS-to-native call path and where it can fail silently
The path is roughly: TypeScript calls a module function, the module serializes arguments across a bridge (or a JSI binding), native code runs, a result serializes back, a promise resolves. Every stage has a failure mode that looks like success.
The worst is the silent stub. It resolves with a structurally plausible value — empty array, zero, undefined — and your app continues into a state that's wrong but not obviously broken. You hear about it in a support ticket three weeks later.
Second worst is the hang. A half-wired native module leaves a promise pending forever. Your spinner never resolves and you blame the network.
Module categories that break first: camera, filesystem, notifications, keychain, secure storage
Ranked by how quickly they break and how badly:
- Camera / media capture — needs a permission dialog, device enumeration, and a hardware-backed pipeline. Usually the first casualty on a new target.
- Secure storage / keychain — Keychain on macOS and iOS, Secret Service (or nothing) on Linux, nothing comparable on web. A runtime can expose one API and quietly back it with three security postures.
- Notifications — user-facing permission on iOS and web, mostly free on macOS, and dependent on a running notification daemon on Linux.
- Filesystem — sandboxed app containers on macOS/iOS, XDG directories on Linux, origin-scoped storage on web.
Secure storage is the category where a "works everywhere" abstraction is most dangerous. Verify what actually backs the store on each target before you put refresh tokens in it.
A TypeScript capability probe that fails loudly instead of degrading quietly
The idea: never call a capability and hope. Call it, validate the shape of the result, classify what came back. The classification logic is the important part — everything else is plumbing.
type CapabilityStatus =
| "ok"
| "unsupported"
| "stubbed"
| "permission-denied"
| "error";
const PERMISSION_ERRORS = new Set([
"NotAllowedError",
"SecurityError",
"EACCES",
"EPERM",
"PERMISSION_DENIED",
]);
export function classifyError(err: unknown): CapabilityStatus {
const name = (err as { name?: string })?.name ?? "";
const code = (err as { code?: string })?.code ?? "";
if (PERMISSION_ERRORS.has(name) || PERMISSION_ERRORS.has(code)) {
return "permission-denied";
}
const message = String((err as Error)?.message ?? "");
if (/not (implemented|supported)|unimplemented/i.test(message)) {
return "stubbed";
}
return "error";
}
The distinction between permission-denied and stubbed is the whole point. A permission-denied result is a product decision — show a prompt, explain the value. A stubbed result is a platform gap — fall back, disable the feature, or block the release. Collapse them into one "failed" bucket and you ship a camera button that does nothing.
Interop failure modes to watch for: missing module vs. stubbed module vs. permission-denied
Three genuinely different bugs:
- Missing module — the import itself fails or the export is
undefined. Loud, caught at build or first render. Not dangerous. - Stubbed module — the import succeeds and resolves with plausible garbage. Silent. Dangerous.
- Permission-denied — the module works and the user said no. Not a bug at all; a UX path.
A probe that only separates ok from not ok treats all three identically, and you'll spend a week debugging a permissions flow that was actually a stub.
The Build Pipeline Boundary: Artifacts, Signing, and CI
What 'one app' does to the artifact graph: one source tree, N signed artifacts
"One binary" is doing heavy lifting in the framing and should be retired. There's one source tree and at least four artifacts:
| Target | Artifact | Signing requirement |
|---|---|---|
| Web | Static bundle + assets | None (TLS at the edge) |
| iPhone | .ipa | Apple Developer cert + provisioning profile |
| macOS | .app in .dmg or .pkg | Developer ID cert + notarization |
| Linux | .deb / .rpm / AppImage / Flatpak | Distro-dependent, often none |
Shared source cuts the authoring cost dramatically. It doesn't touch the artifact count, and artifacts are where long-term maintenance lives.
The costs Exact cannot remove: notarization, iOS signing and provisioning, Linux packaging formats
These aren't gaps in the runtime; they're the cost of shipping software.
macOS notarization needs a hardened runtime, correct entitlements, and an upload to Apple you then wait on. Roughly ten minutes per build, unattended, but it gates your release.
## After building and exporting the app bundle and signing with a Developer ID:
ditto -c -k --keepParent "MyApp.app" "MyApp.zip"
xcrun notarytool submit "MyApp.zip" \
--keychain-profile "notary-profile" \
--wait
xcrun stapler staple "MyApp.app"
xcrun stapler validate "MyApp.app"
iOS signing means certificates, provisioning profiles, and a device-to-entitlement mapping that breaks every time a capability is added. Expo's own tooling (eas build, eas credentials) exists precisely because this is miserable. A new runtime doesn't make it less miserable, it changes who runs the commands.
Linux packaging is a distribution decision with a support tail. Flatpak gives you sandboxing and a single artifact but adds a runtime dependency and a manifest format. AppImage gives you one file and no sandbox. .deb/.rpm give you integration and multiply your support matrix by the number of distros you claim.
Anything the vendor abstracts here is convenience, not elimination. You still own the certificate, the notarization credentials, and the packaging decision.
CI shape — single job vs. matrix, and which is honest
A single CI job producing four artifacts hides which target broke. A matrix job costs more compute and tells you exactly where the failure is.
For a four-target product I'd take the matrix every time, because the failure modes are target-specific by definition. If the runtime really is uniform, the matrix is cheap — all jobs pass. If it isn't, the matrix is the only thing that will say so.
name: build-matrix
on: [push, pull_request]
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- target: web
runner: ubuntu-latest
- target: linux
runner: ubuntu-latest
- target: macos
runner: macos-14
- target: iphone
runner: macos-14
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run probe -- ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: probe-${{ matrix.target }}
path: reports/${{ matrix.target }}.json
fail-fast: false matters. You want all four probe reports even when one target fails, so the diff step has data.
A reproducible command set for a four-target build
I don't have Exact's CLI, so I'm not inventing flags for it. What follows is what the four-target build looks like in Expo's current toolchain conventions, with the Exact-specific step marked. Adapt it after you read the actual docs.
## Web — static export, verified today in Expo SDK 51-era tooling
npx expo export --platform web
## iOS/macOS — generate native projects, then use the platform toolchain
npx expo prebuild --platform ios
npx expo prebuild --platform macos # community target; confirm Exact's own path
## <EXACT-SPECIFIC BUILD STEP — unknown; consult first-party docs>
xcrun notarytool submit MyApp.zip --keychain-profile notary-profile --wait
xcrun stapler staple MyApp.app
The habit worth building is that every line is a command you can run and a result you can record, rather than a pipeline you inherit and never inspect.
A Concrete Capability Audit Harness in TypeScript
The probe module and its TypeScript result shape
Two files. Types first — the result shape is a contract, so get it right once.
// capability-probe/src/types.ts
export type TargetId = "web" | "macos" | "iphone" | "linux" | "node";
export type CapabilityStatus =
| "ok"
| "unsupported"
| "stubbed"
| "permission-denied"
| "error"
| "untested";
export interface ProbeResult {
capability: string;
status: CapabilityStatus;
detail?: string;
durationMs: number;
}
export interface ProbeReport {
target: TargetId;
runtime: { name: string; version: string; userAgent?: string };
startedAt: string;
results: ProbeResult[];
}
The runner, with a timeout so a hanging native module becomes a stubbed result instead of a stuck process:
// capability-probe/src/probe.ts
// trimmed for brevity — imports of ProbeResult and classifyError omitted
const TIMEOUT_MS = 3000;
export async function probe<T>(
capability: string,
run: () => Promise<T>,
validate: (value: T) => string | null,
): Promise<ProbeResult> {
const t0 = performance.now();
const elapsed = () => Math.round(performance.now() - t0);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const value = await Promise.race([
run(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("probe timeout")), TIMEOUT_MS);
}),
]);
const reason = validate(value);
return reason === null
? { capability, status: "ok", durationMs: elapsed() }
: { capability, status: "stubbed", detail: reason, durationMs: elapsed() };
} catch (err) {
if ((err as Error)?.message === "probe timeout") {
return { capability, status: "stubbed", detail: "timed out", durationMs: elapsed() };
}
return {
capability,
status: classifyError(err),
detail: (err as Error)?.message ?? String(err),
durationMs: elapsed(),
};
} finally {
clearTimeout(timer);
}
}
The validate callback is where the audit actually lives. It encodes "what does a real result look like on this platform" — a camera track with non-zero width and height, a file whose read-back matches the write, a notification scheduler that accepted a date.
Running the same probe across all four targets
The capability list, with an availability gate so a missing API is unsupported rather than a thrown ReferenceError:
// capability-probe/src/capabilities.ts
// trimmed for brevity — imports of probe and ProbeResult omitted
export interface Capability {
id: string;
available: () => boolean;
run: () => Promise<ProbeResult>;
}
export const capabilities: Capability[] = [
{
id: "render.textMetrics",
available: () => typeof document !== "undefined",
run: () =>
probe("render.textMetrics", async () => {
const el = document.createElement("span");
el.textContent = "MMMM";
el.style.cssText =
"position:absolute;font:16px system-ui;white-space:pre";
document.body.appendChild(el);
const width = el.getBoundingClientRect().width;
el.remove();
return width;
}, (w) => (w > 0 ? null : "zero-width text measurement")),
},
{
id: "input.focusVisible",
available: () => typeof CSS !== "undefined" && !!CSS.supports,
run: () =>
probe("input.focusVisible", async () =>
CSS.supports("selector(:focus-visible)"),
(ok) => (ok ? null : ":focus-visible unsupported")),
},
{
id: "camera.capture",
available: () =>
typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia,
run: () =>
probe("camera.capture", async () => {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];
const settings = track?.getSettings?.() ?? {};
track?.stop();
return settings;
}, (s) => (s.width && s.height ? null : "track reported no dimensions")),
},
{
id: "fs.writeRead",
available: () => typeof process !== "undefined" && !!process.versions?.node,
run: () =>
probe("fs.writeRead", async () => {
const { writeFile, readFile, unlink } = await import("node:fs/promises");
const path = `/tmp/exact-probe-${Date.now()}.txt`;
const payload = `probe-${Math.random().toString(36).slice(2)}`;
await writeFile(path, payload, "utf8");
const back = await readFile(path, "utf8");
await unlink(path);
return back === payload;
}, (ok) => (ok ? null : "read-back mismatch")),
},
{
id: "notifications.schedule",
available: () => typeof Notification !== "undefined",
run: () =>
probe("notifications.schedule", async () => Notification.permission,
(p) => (p === "granted" ? null : `permission is ${p}`)),
},
{
id: "storage.roundTrip",
available: () => typeof localStorage !== "undefined",
run: () =>
probe("storage.roundTrip", async () => {
const key = `probe-${Date.now()}`;
localStorage.setItem(key, "1");
const value = localStorage.getItem(key);
localStorage.removeItem(key);
return value;
}, (v) => (v === "1" ? null : "storage round-trip failed")),
},
];
Two honesty notes. fs.writeRead is a Node-only control here — on real targets you'd swap in expo-file-system and keep the same read-back assertion. And storage.roundTrip deliberately uses localStorage as a stand-in; the secure-storage version has to go through the platform keychain module, and I'd want to see what backs it before trusting it.
The runner writes one JSON report per target:
// capability-probe/src/run.ts
// trimmed for brevity — imports of mkdirSync, writeFileSync, capabilities and types omitted
const target = (process.argv[2] ?? "node") as TargetId;
const report: ProbeReport = {
target,
runtime: {
name: typeof process !== "undefined" ? "node" : "unknown",
version: process?.versions?.node ?? "n/a",
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
},
startedAt: new Date().toISOString(),
results: [],
};
for (const capability of capabilities) {
report.results.push(
capability.available()
? await capability.run()
: {
capability: capability.id,
status: "unsupported",
detail: "API absent in this runtime",
durationMs: 0,
},
);
}
mkdirSync("reports", { recursive: true });
writeFileSync(`reports/${target}.json`, JSON.stringify(report, null, 2));
console.log(
`${target}: ` +
report.results.map((r) => `${r.capability}=${r.status}`).join(" "),
);
Emitting a machine-readable capability report and diffing runs across targets
The diff is where the audit pays off. It builds the matrix, flags rows where statuses disagree, and exits non-zero so CI catches regressions.
// capability-probe/src/diff.ts
// trimmed for brevity — imports of readdirSync, readFileSync, join and types omitted
const dir = process.argv[2] ?? "reports";
const reports: ProbeReport[] = readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")) as ProbeReport);
const targets = reports.map((r) => r.target).sort();
const capabilities = [
...new Set(reports.flatMap((r) => r.results.map((x) => x.capability))),
].sort();
const statusOf = new Map<string, string>();
for (const report of reports) {
for (const result of report.results) {
statusOf.set(`${report.target}::${result.capability}`, result.status);
}
}
const width = Math.max(...capabilities.map((c) => c.length)) + 2;
console.log("capability".padEnd(width) + targets.map((t) => t.padEnd(20)).join(""));
let divergences = 0;
for (const capability of capabilities) {
const row = targets.map((t) => statusOf.get(`${t}::${capability}`) ?? "untested");
const distinct = new Set(row);
const divergent = distinct.size > 1 && (distinct.has("ok") || distinct.has("stubbed"));
if (divergent) divergences += 1;
console.log(capability.padEnd(width) + row.map((s) => s.padEnd(20)).join(""));
}
if (divergences > 0) {
console.error(`\n${divergences} capability divergence(s) between targets`);
process.exit(1);
}
What a clean pass looks like vs. a pass that hides a stub
Clean pass: every capability is ok on all four targets, or unsupported on all four, or permission-denied on the targets where a user declined. Divergence count is zero and every non-ok is explainable.
A pass that hides a stub: the diff is green because a capability returns ok everywhere, but durationMs is ~0 on three targets and ~400ms on the fourth. A sub-millisecond camera init isn't a fast camera; it's a resolved promise with nothing behind it. Add a duration sanity band per capability — if a probe that normally takes 200ms returns in 0ms, call it stubbed regardless of what validate said.
That check isn't in the code above. It's the first thing I'd add after the first real run.
Reading the Results: Divergence Patterns That Predict Production Pain
Capability results table: expected behavior, leaky symptom, how to confirm
| Capability class | Expected behavior | Leaky symptom | How to confirm |
|---|---|---|---|
| Layout & text metrics | Same string measures within ~1% across targets | One-word line-wrap differences in dense tables | Measure a fixed string at a fixed font; log widths |
| Scroll physics | Platform-native feel, same content order | Overscroll missing or doubled | Flick-scroll a long list; compare deceleration |
| Focus & keyboard nav | Tab order stable; ring visible on desktop and web | Ring invisible on desktop, present on web | Tab through a form, screenshot the focus state |
| Accessibility tree | Every control exposes a name and role | VoiceOver reads an empty region | Inspect with the platform a11y inspector |
| Filesystem | Read-back equals write | File written to a path that does not persist | Round-trip assertion in the probe |
| Camera / media | Stream resolves with dimensions | Instant resolve; settings is {} | Check getSettings().width > 0 |
| Notifications | Permission prompt appears; scheduled item fires | Permission returns default forever | Check permission transitions |
| Secure storage | Encrypted at rest per platform primitive | Backed by plain files or localStorage | Inspect the backing store, not the API |
| Background lifecycle | State preserved across suspend/resume | Cold restart after backgrounding | Background for 60s, resume, log state |
Distinguishing 'unsupported' from 'untested' from 'broken'
This is why the status enum has six values instead of two.
unsupportedis a documented platform reality. Make a product decision and move on.untestedis your gap, not the runtime's. The probe couldn't run — no device, no permission, no fixture. Don't ship on anuntestedcell without a plan to fill it.broken(rendered aserrorin the harness) is a defect. Something should have worked and didn't.
The pattern I see most on cross-platform teams is treating untested as ok because the row was green in the last build. An untested cell that stays untested for two sprints becomes a production incident with a known-but-ignored root cause.
The three divergence patterns that predict real production pain
These three shapes reliably correlate with a bad launch.
1. Asymmetric stubbing. A module is ok on macOS and iPhone, stubbed on web and Linux. Highest risk of the three, because your primary development targets can't reproduce the bug. If your engineers work on macOS and your Linux users exist, you have a class of bug that's invisible to the people who fix bugs.
2. Permission asymmetry. ok on desktop, permission-denied on iPhone and web. Not the runtime's fault, but it means the feature's happy path is the minority path. Without a designed non-permission state, one target ships broken.
3. Timing asymmetry. All targets report ok, but one takes 10× longer for the same operation. Usually a bridge hop or a serialization cost. Won't show up in a functional test; will show up in your first performance complaint.
Performance and Startup Characteristics
Measurement method before numbers: device, build mode, cold-start definition
I have no performance numbers for Exact and I'm not going to produce any. What I can do is define the measurement, so anyone claiming a number has to state these alongside it:
- Device — exact model or, on desktop, CPU + GPU + RAM.
- Build mode — release build, minified, bundled; never a dev build.
- Cold-start definition — process launch to first interactive frame, with the app not resident. A "warm" start where the VM is already cached is a different number and often 2–3× faster.
- Sample count and variance — median of 20 launches, plus p95. A single launch is an anecdote.
If a cross-platform benchmark omits any of those four, discount it entirely. Not skepticism — it's the minimum bar for the number to mean anything.
What to measure on each target: time to first paint, JS bundle parse, native module init
Three phases, separable if the runtime lets you instrument them:
// Boot-phase timing. Log once at module scope, before any UI work.
const t0 = performance.now();
queueMicrotask(() => {
const afterMicrotask = performance.now();
requestAnimationFrame(() => {
const firstFrame = performance.now();
console.log(JSON.stringify({
phase: "boot",
toMicrotaskMs: +(afterMicrotask - t0).toFixed(1),
toFirstFrameMs: +(firstFrame - t0).toFixed(1),
}));
});
});
- JS bundle parse/compile dominates a cold start with a large bundle. On Hermes that's bytecode precompilation at build time, which is why bundle size hurts less than it does on the web.
- Native module init shows up as the gap between microtask-resolved and first-frame-painted. A large gap on one target and not the others is your bridge initialization cost.
- Time to first paint is the only number users feel.
Run the same script on all four targets, same build mode, and compare the deltas rather than the absolutes. A uniformly slower cold start is a budget line. A wildly asymmetric one is an architecture problem you can't tune away.
Why cross-target benchmark claims without methodology should be discounted
Because the four backends aren't doing the same work. Web startup includes HTML parse and CSS layout. macOS startup includes Launch Services and Gatekeeper verification. Linux startup includes dynamic library resolution against the user's system. iPhone startup includes device-specific memory pressure handling.
A single millisecond figure spanning all four measures the runtime's own overhead, not the user's experience. That's a legitimate number — just not the one the headline implies. Ask for the four-phase breakdown, or read the number as "runtime overhead only."
Security and Trust Surface That Actually Changes
Update channel and remote code execution constraints, especially on iOS
This is where a universal runtime's convenience meets a contract you didn't write.
Web and Linux apps can ship updates freely. macOS requires notarization per build but can self-update. iPhone is the constrained target: Apple's App Store Review Guidelines restrict downloading code that introduces or changes app functionality (Guideline 2.5.2 covers self-contained apps; verify the current wording before relying on it). That's why over-the-air update systems on iOS ship JS bundles inside an interpreted-code allowance and can't ship new native code.
What that means for a four-target runtime, inference, not tested: if the update mechanism treats all four targets as one channel, you may end up with an update path that's legal on three and requires App Store review on the fourth. Worth asking before adoption, because retrofitting a review gate onto an OTA pipeline is expensive.
The native bridge as a privilege boundary between JS and the OS
Every call from JS into native code is a privilege escalation. JS has no business touching the filesystem or the camera; native code holds that authority, and the bridge is the gate.
Concrete controls to verify:
- Argument validation on the native side. A bridge that trusts JS-supplied paths can be persuaded to read outside the app container. If the runtime accepts a path from JS, the native layer must canonicalize and confine it.
- Permission checks in native code. The check has to live where JS can't skip it, not in a JS-side wrapper.
- Surface minimization. Every exposed native method is attack surface. A runtime that exposes 400 modules by default has 400 doors; you want an allowlist.
Localhost and dev-server exposure during development builds
Development builds open a server. Metro, or whatever Exact uses, typically listens on a port and serves your bundle over HTTP. On a machine on a shared network, that's a bundle and source map available to anyone who can reach the port.
## Check what is actually listening during `expo start`
lsof -nP -iTCP -sTCP:LISTEN | grep -E '8081|19000|19001'
## Expected: a listener on 127.0.0.1, not 0.0.0.0.
Bound to 0.0.0.0, anyone on the coffee-shop Wi-Fi can fetch your development bundle. This is well understood, widely documented, and still common. Bind to loopback and use a tunnel when you need a physical device.
Practical verification steps, staying inside authorized testing
All of the above is verifiable on your own build on your own machine. That's the whole scope — no probing other people's apps.
- Bind the dev server to loopback and confirm with
lsof. - Confirm
devandreleasebuilds differ in bundle contents: a release build should contain no source maps and no dev-only modules. - Inspect the IPC/bridge surface your app actually exposes and remove anything unused.
- For any native module taking a path or URL from JS, test with
../and absolute paths against a local build to see how the native layer responds. - Verify entitlement files contain only the capabilities you use.
Step 4 is the one people skip. Write it as an automated test now, while the module list is short.
Where This Fits Against the Alternatives
Comparison table: Exact, Expo with per-platform targets, Tauri, Electron, React Native macOS/Windows
| Option | Targets | Rendering | Distribution | Maturity signal |
|---|---|---|---|---|
| Exact (as reported) | web, macOS, iPhone, Linux | Unknown | Unknown | Single secondary report |
| Expo with per-target config | web, iOS, Android (+ community desktop) | React Native native views / DOM on web | EAS or local | Mature, documented track record |
| Tauri | desktop + mobile (v2) | System webview (WKWebView / WebView2 / WebKitGTK) | Native installers | Active, documented |
| Electron | desktop | Bundled Chromium + Node | Native installers | Very mature, large artifacts |
| react-native-macos / -windows | desktop, platform-specific | Native views | Native installers | Microsoft-maintained forks |
Expo's own per-target setup is the relevant baseline: it already shares source across web and iOS, and the desktop story is the community gap. If Exact closes that gap with a single target list, that's the actual value proposition — not "four platforms" as a novelty but "macOS and Linux without a separate app."
The tradeoff I would actually make, and for which team shape
For a small TypeScript team shipping a content-heavy or workflow app to web and desktop with one mobile companion, a unified runtime is the right bet — with a capability matrix as the gate.
For a team whose product value is platform integration (media editing, hardware, deep OS integration), I wouldn't take it. The bridge cost per platform is the feature, and abstracting it away removes your differentiator.
For an App-Store-only product with no desktop ambition, web and Linux support are free options you're paying for in complexity. Skip the universal runtime and use the boring, well-trodden path.
When a single runtime is the wrong call — heavy native UI, Linux-first distribution, App Store-only constraints
Three disqualifiers, in order of how often they apply:
- Heavy native UI. If your UI depends on platform-specific controls — macOS sidebars with source lists, iOS sheets with detents, native context menus — a shared component model will either not expose them or expose them inconsistently. The workaround cost exceeds the savings.
- Linux-first distribution. If Linux is a primary market, packaging and compositor questions aren't edge cases; they're the product. A runtime that treats Linux as a fourth target rather than a first-class one will show, most visibly at fractional scaling.
- App Store-only constraints. If iOS review timelines are already your release bottleneck, adding a runtime with an unknown update story makes the bottleneck worse.
Decision Guide for TypeScript Teams
Questions to answer before adopting a universal runtime
- Which SDK version does the runtime track, and what's its lag behind the iOS target?
- What renders the web target — DOM, canvas, or bridged native views? (Test find-in-page; it takes 30 seconds.)
- Which native modules resolve on Linux, and what backs secure storage there?
- What's the Linux artifact format, and who owns packaging?
- What happens to our app if the runtime stalls for six months? Is there a documented exit path?
- Does the update mechanism treat all four targets as one channel?
- What's the license, and does it permit the distribution model we need?
A short adoption checklist with pass/fail criteria
| Check | Pass criteria | Fail criteria |
|---|---|---|
| Capability matrix | All nine classes are ok or a documented unsupported | Any class is untested after two weeks |
| Divergence harness | Runs in CI on all four targets | Diff runs on one target only |
| Secure storage | Backed by Keychain / Secret Service, verified | Backed by files or localStorage |
| Update path | Per-target policy documented | Single channel, iOS legality unverified |
| Packaging | Linux format chosen and built in CI | "We'll figure out Linux packaging later" |
| Exit plan | Ejected native project builds | No path off the runtime |
Migration cost estimate framing, not a fake number
No week count from me. Estimate in these units instead:
- Screens that need per-target branches — count them. Roughly a day of design plus a day of testing across four targets, each.
- Native modules that must be written or replaced — each is a bridge module, a permission flow, and 4× the test surface.
- Signing and notarization setup — one engineer, two to three days, plus a recurring per-release cost.
- CI matrix — one engineer, one to two days, plus ongoing compute.
- The capability harness — one engineer, one week, then near zero. Cheapest item on the list, and it pays for the rest.
If the sum exceeds the cost of a second platform-specific codebase at your team size, shared source isn't saving you anything. That comparison is the entire business case.
What This Audit Could Not Verify
Claims resting on a single secondary report
Everything specific to Exact: that it exists as a released product, that the four targets work at feature parity, that it accepts TypeScript, that it's derived from Expo's toolchain at all. My source is one aggregator item dated 2026-11-10 with a headline and no body. A single secondary source is enough to write an audit plan. It isn't enough to write a review.
Items that need first-party docs, source, or a local build to settle
Each of these is a TODO in my notes, not a sentence in a conclusion:
- The rendering architecture per target (DOM vs. canvas vs. bridged views on web; AppKit/UIKit mapping on desktop and mobile).
- Supported module list and per-target support status.
- Linux windowing assumptions (Wayland native, XWayland, or both) and fractional scaling behavior.
- Distribution format and update channel.
- License and source availability.
- Minimum OS versions, including which Linux distributions are in scope.
- Startup performance with a stated method.
How I would update this post once those exist
- Replace the inference table with a measured capability matrix from an actual build.
- Fill in the rendering-architecture section with the real per-target mapping.
- Replace the
unknownline in the build command block with the real invocation. - Add startup numbers with device, build mode, and cold-start definition attached.
- Publish the filled-in
reports/*.jsonalongside the post so the diff is reproducible.
If those five things never materialize, that's an answer too.
Conclusion — The Boundary Is the Product
Restating the position: shared source is achievable today, shared behavior is the open question
A universal runtime is a bet that the seams between four platforms can be made thin enough for one team to maintain. That bet is worth making. It isn't the same as the claim that behavior is uniform, and the gap between the two is the capability matrix.
The position I'd defend: judge a cross-platform runtime by the quality of its failure modes, not by the length of its platform list. A runtime that says unsupported honestly on Linux is worth more than one that returns a plausible object and lets you ship a dead button. The harness is how you tell them apart.
The artifact graph, meanwhile, doesn't collapse. One source tree, four signed artifacts, four sets of platform obligations. Sharing source is a real win in authoring cost and no win at all in distribution cost, and the vendors who imply otherwise are selling the part that was already easy.
The two experiments any team should run first
Before you commit a quarter, do these two things. Together they cost about a week.
Experiment one: the capability probe on all four targets. Port capabilities.ts to your runtime's module APIs, run it on each target, diff. If you can't get all four targets running within a week, the adoption cost is already higher than the pitch suggested — and you learned that cheaply.
Experiment two: the ugly screen. Take your most platform-specific screen — the one with the native picker, the permission prompt, and the keychain-backed login — and build only that, on all four targets. Not the easy screen. The ugly one. If it works everywhere with per-target branches you can count on one hand, the universal runtime is a good bet for your team. If it needs four branches and a native module, you have your answer before you've migrated anything.
That's the audit. Everything past it is verification.
Further Reading
- Secondary report: "Expo releases Exact, a runtime that builds one app for web, Mac, iPhone and Linux" — the seed item this post responds to; single source, no first-party confirmation attached.
- Expo changelog — where a release of this kind would be announced first-party; check here before trusting any secondary summary.
- Expo documentation — module list, SDK versioning, and prebuild/export commands referenced above.
- React Native repository — the architecture and bridge model that most Expo-based runtimes inherit.
- Apple: Notarizing macOS software before distribution — the notarization steps and
notarytoolusage in the build section. - App Store Review Guidelines — verify the current wording of the guideline restricting downloaded executable code before designing an update channel.
- Tauri — system-webview alternative with desktop and mobile targets.
- Electron documentation — bundled-Chromium alternative; useful as the artifact-size baseline.
- microsoft/react-native-macos — the community desktop path Expo does not ship.
- microsoft/react-native-windows — same, for Windows.
- Flatpak — one of the Linux packaging models referenced in the artifact discussion.
- MDN:
:focus-visible— the selector the focus-ring probe checks for.
More posts

TypeScript 7.1 Beta Restores the Compiler API: What Transformer and Lint-Rule Authors Get Back
TypeScript 7.1 Beta restores the compiler API and adds first-class Vue and Svelte support, giving transformer and lint-rule authors, IDE integrations, and build pipelines a clearer path for tooling and migration planning.

Server Actions and Flight Endpoints: Auditing RSC Trust Boundaries After the Authless RCE Report
A deep technical walkthrough of how React Server Components' Flight protocol and Server Actions handle serialized payloads, why the reported authless RCE bypassed authentication at the trust boundary, and how to audit and harden your own RSC endpoints.

Measuring Real Cost per Agent Task on Muse Ember With a JavaScript Harness
Build a JavaScript harness that models Muse Ember’s real cost per agent task by breaking down $0.10/M-token pricing, the 200K-context caveat, tool calls, retries, and context growth against frontier-model trade-offs.
Loading comments…