Auditing Expo Exact's Runtime Boundaries on Web, macOS, iPhone, and Linux

Auditing Expo Exact's Runtime Boundaries on Web, macOS, iPhone, and Linux

pr0h0••37 min read•coding
AI Usage (85%)
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:

  1. 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.
  2. 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

StatementStatusBasis
A runtime called Exact has been reported as released by ExpoConfirmed as a report, not as a first-party fact I verifiedSingle secondary item, dated 2026-11-10
Targets are web, macOS, iPhone, LinuxConfirmed as stated in the reportHeadline text
It accepts TypeScript sourceInferenceThe framing is "TypeScript app"; no API surface is quoted
Rendering approach is canvas vs. native viewsUnknownNot stated anywhere in the source
Native module coverage beyond what Expo already shipsUnknownNot stated
Linux distribution formatUnknownNot stated
Licensing and source availabilityUnknownNot 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 classwebmacOSiPhoneLinux
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.

CheckHow to testWhat divergence looks like
Text metricsRender a fixed string at a fixed font, measure getBoundingClientRect().widthWidth differs by >1px per 100px; line wrapping flips
Scroll physicsFlick-scroll a long list; compare deceleration and overscrollInstant stop on web, rubber-band on iOS, no momentum on Linux
Focus ringsTab through a formRing appears on web, absent on iOS, wrong color on Linux
Hit slopTap a 24px icon with a fingerWorks on iPhone, unreliable on web when emulated, fine with a mouse on desktop
A11y treeInspect with platform toolingInteractive 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:

  1. Camera / media capture — needs a permission dialog, device enumeration, and a hardware-backed pipeline. Usually the first casualty on a new target.
  2. 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.
  3. Notifications — user-facing permission on iOS and web, mostly free on macOS, and dependent on a running notification daemon on Linux.
  4. 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:

TargetArtifactSigning requirement
WebStatic bundle + assetsNone (TLS at the edge)
iPhone.ipaApple Developer cert + provisioning profile
macOS.app in .dmg or .pkgDeveloper ID cert + notarization
Linux.deb / .rpm / AppImage / FlatpakDistro-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 classExpected behaviorLeaky symptomHow to confirm
Layout & text metricsSame string measures within ~1% across targetsOne-word line-wrap differences in dense tablesMeasure a fixed string at a fixed font; log widths
Scroll physicsPlatform-native feel, same content orderOverscroll missing or doubledFlick-scroll a long list; compare deceleration
Focus & keyboard navTab order stable; ring visible on desktop and webRing invisible on desktop, present on webTab through a form, screenshot the focus state
Accessibility treeEvery control exposes a name and roleVoiceOver reads an empty regionInspect with the platform a11y inspector
FilesystemRead-back equals writeFile written to a path that does not persistRound-trip assertion in the probe
Camera / mediaStream resolves with dimensionsInstant resolve; settings is {}Check getSettings().width > 0
NotificationsPermission prompt appears; scheduled item firesPermission returns default foreverCheck permission transitions
Secure storageEncrypted at rest per platform primitiveBacked by plain files or localStorageInspect the backing store, not the API
Background lifecycleState preserved across suspend/resumeCold restart after backgroundingBackground for 60s, resume, log state

Distinguishing 'unsupported' from 'untested' from 'broken'

This is why the status enum has six values instead of two.

  • unsupported is a documented platform reality. Make a product decision and move on.
  • untested is your gap, not the runtime's. The probe couldn't run — no device, no permission, no fixture. Don't ship on an untested cell without a plan to fill it.
  • broken (rendered as error in 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.

  1. Bind the dev server to loopback and confirm with lsof.
  2. Confirm dev and release builds differ in bundle contents: a release build should contain no source maps and no dev-only modules.
  3. Inspect the IPC/bridge surface your app actually exposes and remove anything unused.
  4. 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.
  5. 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

OptionTargetsRenderingDistributionMaturity signal
Exact (as reported)web, macOS, iPhone, LinuxUnknownUnknownSingle secondary report
Expo with per-target configweb, iOS, Android (+ community desktop)React Native native views / DOM on webEAS or localMature, documented track record
Tauridesktop + mobile (v2)System webview (WKWebView / WebView2 / WebKitGTK)Native installersActive, documented
ElectrondesktopBundled Chromium + NodeNative installersVery mature, large artifacts
react-native-macos / -windowsdesktop, platform-specificNative viewsNative installersMicrosoft-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:

  1. 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.
  2. 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.
  3. 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

  1. Which SDK version does the runtime track, and what's its lag behind the iOS target?
  2. What renders the web target — DOM, canvas, or bridged native views? (Test find-in-page; it takes 30 seconds.)
  3. Which native modules resolve on Linux, and what backs secure storage there?
  4. What's the Linux artifact format, and who owns packaging?
  5. What happens to our app if the runtime stalls for six months? Is there a documented exit path?
  6. Does the update mechanism treat all four targets as one channel?
  7. What's the license, and does it permit the distribution model we need?

A short adoption checklist with pass/fail criteria

CheckPass criteriaFail criteria
Capability matrixAll nine classes are ok or a documented unsupportedAny class is untested after two weeks
Divergence harnessRuns in CI on all four targetsDiff runs on one target only
Secure storageBacked by Keychain / Secret Service, verifiedBacked by files or localStorage
Update pathPer-target policy documentedSingle channel, iOS legality unverified
PackagingLinux format chosen and built in CI"We'll figure out Linux packaging later"
Exit planEjected native project buildsNo 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

  1. Replace the inference table with a measured capability matrix from an actual build.
  2. Fill in the rendering-architecture section with the real per-target mapping.
  3. Replace the unknown line in the build command block with the real invocation.
  4. Add startup numbers with device, build mode, and cold-start definition attached.
  5. Publish the filled-in reports/*.json alongside 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

Share this post

More posts

Loading comments…