Your Build Says Fabric. Ask the Runtime.

A green build is a configuration fact. Fabric being live is a runtime fact. They are not the same claim.

There is a specific flavour of wasted afternoon where you flip newArchEnabled=true, the build goes green, the app launches, and you spend the next two hours debugging a Fabric-only behaviour in an app that is quietly still running Paper.

I wrote earlier about auditing whether your dependencies even declare New Architecture support. That audit answers "will this app's dependency graph let me migrate?" It cannot answer "is this app, in this build, on this device, right now, actually rendering through Fabric?"

That second question has a different answer surface, and you can read it in about ten seconds once you know where to look.

Why the flag is not the answer

Enabling the New Architecture is a build-time configuration decision. Activation is a runtime outcome. Three things drive them apart:

  • Silent fallback. A misconfigured native dependency can drop the runtime back to Paper without producing a build-time error. Nothing goes red. You just get the old renderer.
  • Cache masking. A stale Metro or DerivedData cache can hand you a binary whose behaviour does not match the flag state you just edited. The config on disk and the bytes you are running have diverged.
  • Per-platform asymmetry. iOS and Android read their toggles from separate manifests — RCTNewArchEnabled on one side, newArchEnabled on the other. A repo can be Fabric on Android and Paper on iOS, and the only thing that catches it is checking per run, per platform.

This is the same shape as a green CI build that never ran the typechecker. The pipeline is telling you the truth about what it did, which is not the property you actually care about.

Signal 1: the Metro startup log

At launch, the runtime prints an AppRegistry.runApplication line to the Metro console, and its second argument is the initial render payload. That payload is the check.

Every React Native library I maintain ships the same line in its CONTRIBUTING.md, because it is the first thing a contributor should confirm before they file a bug. From react-native-in-app-review:

Running "InAppReviewExample" with {"fabric":true,"initialProps":{"concurrentRoot":true},"rootTag":1}

And from react-native-step-counter, the same recipe with a different app name:

Running "StepCounterExample" with {"fabric":true,"initialProps":{"concurrentRoot":true},"rootTag":1}

Two keys carry the weight:

  • "fabric": true — the Fabric renderer is mounting the tree, not Paper.
  • "concurrentRoot": true — the React concurrent root is enabled, which Fabric's threading model requires.

The app name and rootTag are per-repository noise. The payload keys are the invariant.

Signal 2: ask the JS runtime directly

Fabric leaves a marker on the global object. Under Paper it is undefined:

const uiManager = global?.nativeFabricUIManager ? "Fabric" : "Paper";
console.log(`Using ${uiManager}`);

This is the check to reach for when the Metro log is not in front of you — a device with no live Metro attach, a QA build, a teammate's screen recording. It runs inside the app and surfaces through whatever log transport you already have.

Pair it with the log inspection rather than choosing between them. The community RFC discussion on New-Architecture rollout records installs where the concurrentRoot payload never appears in the terminal even though the New Architecture is genuinely active. If your only check is the terminal payload, that install reads as a false negative and you go hunting for a problem you do not have.

Reading the log without opening a GUI

React Native DevTools will show you all of this interactively. It is also the wrong instrument the moment you want the answer in a script, in CI, or in an agent's hands — you cannot tail a GUI.

Metro exposes the Chrome DevTools Protocol directly, and that you can tail:

GET  http://localhost:8081/json                                   → list of CDP targets
WS   ws://localhost:8081/inspector/debug?device=<id>&page=<pageId> → per-target stream

Each entry in the /json response carries a webSocketDebuggerUrl. Open it, then send two enable messages before expecting anything:

{"id": 1, "method": "Runtime.enable", "params": {}}
{"id": 2, "method": "Console.enable", "params": {}}

After that the server streams Runtime.consoleAPICalled events, each with a type (log / info / warn / error), an args array of CDP RemoteObjects, and a millisecond timestamp. Render arg.value for primitives and fall back to arg.description for object-typed values.

That is the entire mechanism: one HTTP fetch and one WebSocket. On Node 21+ the built-in WebSocket covers it, so a minimal client needs no dependencies at all. I packaged mine as the rn-metro-console skill in RN Agents Kit so I would stop rewriting it.

Four failure modes are worth knowing before you debug the debugger:

  • Metro is not running. Port 8081 has to be open. Start Metro first.
  • /json is empty. Nothing is attached to this Metro instance — either the app is not running, or it is talking to a different Metro.
  • The stream opens but stays silent. The app was force-quit. Reload from the dev menu or relaunch.
  • ws fails to resolve. If your client falls back to the ws module, run it from the project root so node_modules resolves. Running it from ~ will not.

Two honest limits. This surfaces console.* calls only — native logs, native crashes, and JSI-internal output do not appear here, and for those adb logcat, the Xcode console, and Crashlytics stay authoritative. And whether any of it works under Expo Go or expo-dev-client is genuinely unknown to me: the implementation has no Expo handling and no Expo exclusion, it just talks to whatever Metro CDP endpoint you point it at. I have not tested it there, so I am not going to claim it either way.

What this does not tell you

Bridgeless is a separate question. The payload keys confirm Fabric. They do not distinguish bridgeless mode from bridged-Fabric. Probing that means reaching for global._IS_FABRIC or global.RN$Bridgeless, both undocumented and engine-version-dependent, and I would not build a migration gate on them.

Dependency support is a separate question, at a separate time. npx expo-doctor validates your dependencies against the React Native Directory's compatibility metadata; helpers like new-arch-helper and rn-chk-new-arch read your autolinked dependencies and query the same Directory per package. These are authoring-time checks against the dependency graph — exactly what the audit post is about. They tell you nothing about whether the app in front of you booted into Fabric. Run both; do not substitute one for the other.

None of this is Meta-documented. I could not find a first-party reactnative.dev source for the payload keys. What I have is my own repos' contributor docs, a Nicola Corti conference talk cited on Stack Overflow for the nativeFabricUIManager probe, and the RFC discussion for the terminal-visibility caveat. That is enough to act on and not enough to call a specification, and it would be dishonest to present it as one.

The smaller promise

Flip the flag, then ask the runtime. Read the payload for fabric:true and concurrentRoot:true, keep the nativeFabricUIManager probe as the second opinion, and tail Metro's CDP socket when you need the answer somewhere a GUI cannot go.

It is a ten-second check that turns "the build is green" into "the renderer is live" — and those two sentences have cost me very different amounts of an afternoon.