<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mason Roy</title>
    <description>The latest articles on DEV Community by Mason Roy (@mason_roy).</description>
    <link>https://dev.to/mason_roy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4131409%2F2d3489ff-c5f3-4c7f-b52a-2b57fcca142e.png</url>
      <title>DEV Community: Mason Roy</title>
      <link>https://dev.to/mason_roy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mason_roy"/>
    <language>en</language>
    <item>
      <title>React Native New Architecture in 2026: What Developers Need to Know</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Mon, 12 Oct 2026 10:11:38 +0000</pubDate>
      <link>https://dev.to/mason_roy/react-native-new-architecture-in-2026-what-developers-need-to-know-4h49</link>
      <guid>https://dev.to/mason_roy/react-native-new-architecture-in-2026-what-developers-need-to-know-4h49</guid>
      <description>&lt;p&gt;For eight years, every React Native app in the world shipped the same compromise: a JavaScript bridge that serialized every call between your JS code and the native platform as JSON, then shipped it across an async boundary. It worked. It also quietly capped the performance ceiling of the entire framework.&lt;/p&gt;

&lt;p&gt;In October 2025, Meta shipped React Native 0.82 and removed the ability to turn the bridge back on. The React Native New Architecture — in development since 2018 — is no longer a migration path. It's the only architecture. If you ship a React Native app in 2026, you're shipping on Fabric, TurboModules, JSI, and bridgeless mode, whether you planned to or not.&lt;/p&gt;

&lt;p&gt;This guide explains what actually changed, what breaks in practice, and what the New Architecture means for anyone building mobile apps in 2026 — including the AI tools now generating most of the new React Native code in the wild.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Image suggestion: a modern React Native dev setup — editor + physical device running the app.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the New Architecture actually is
&lt;/h2&gt;

&lt;p&gt;The React Native New Architecture is the collective name for four related changes that replace the legacy bridge-based runtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSI (JavaScript Interface)&lt;/strong&gt; — a C++ API that lets JavaScript hold direct references to native objects and call them synchronously. No more JSON serialization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fabric&lt;/strong&gt; — the new renderer. React's fiber tree is reconciled directly against native view hierarchies via JSI, with a shared C++ core instead of two parallel implementations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TurboModules&lt;/strong&gt; — the new native module system. Modules are lazy-loaded and expose typed, JSI-backed methods generated from a single TypeScript or Flow spec via &lt;strong&gt;Codegen&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bridgeless mode&lt;/strong&gt; — the runtime mode where the legacy async bridge is never instantiated at all. Startup is faster, memory is lower, and a whole class of lifecycle bugs disappears.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short: the New Architecture deletes the slowest piece of React Native and replaces it with something that behaves much more like a native app that happens to be scripted in JavaScript.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 40-60 word answer, for the Google snippet readers
&lt;/h3&gt;

&lt;p&gt;The React Native New Architecture is a complete overhaul of how JavaScript talks to native code on iOS and Android. It replaces the asynchronous JSON bridge with JSI for direct, synchronous calls, Fabric for rendering, and TurboModules for native modules — enabled by default from React Native 0.76 and mandatory from 0.82.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the legacy bridge had to die
&lt;/h2&gt;

&lt;p&gt;To understand why Meta spent seven years rewriting the core, you have to understand what the legacy architecture actually did on every single frame.&lt;/p&gt;

&lt;p&gt;Imagine a &lt;code&gt;FlatList&lt;/code&gt; scrolling at 60 fps. In the old world:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The scroll gesture fires on the native UI thread.&lt;/li&gt;
&lt;li&gt;The event gets serialized to JSON.&lt;/li&gt;
&lt;li&gt;The JSON crosses the bridge to the JavaScript thread, asynchronously.&lt;/li&gt;
&lt;li&gt;JS runs, computes new state, and sends view updates back as JSON.&lt;/li&gt;
&lt;li&gt;The native thread deserializes and applies them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step was async. Every step allocated. Every step did string work. A list that was trivial in Swift or Kotlin became a performance puzzle in React Native — hence the entire cottage industry of libraries like &lt;code&gt;FlashList&lt;/code&gt;, &lt;code&gt;Reanimated&lt;/code&gt;, and &lt;code&gt;Gesture Handler&lt;/code&gt;, each of which exists in part because the bridge made the obvious implementation too slow.&lt;/p&gt;

&lt;p&gt;JSI killed the serialization. A JS function can now hold a C++ &lt;code&gt;HostObject&lt;/code&gt; reference and call native methods &lt;strong&gt;synchronously&lt;/strong&gt;, on the same thread, with the same cost as any other C++ call. That one change is the foundation under everything else in the New Architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fabric: what changes in rendering
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://reactnative.dev/architecture/fabric-renderer" rel="noopener noreferrer"&gt;Fabric&lt;/a&gt; is the New Architecture's renderer, and it's the piece most developers will feel directly, even if they never touch native code.&lt;/p&gt;

&lt;p&gt;Four things that are new in Fabric:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous layout.&lt;/strong&gt; &lt;code&gt;onLayout&lt;/code&gt; and &lt;code&gt;measure&lt;/code&gt; calls used to be async because they had to cross the bridge. Under Fabric, layout happens on the UI thread via JSI, and measurement is synchronous. A huge amount of brittle &lt;code&gt;setTimeout&lt;/code&gt;-based layout code can simply go away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared C++ core.&lt;/strong&gt; The old renderer had two separate implementations — one for iOS (Objective-C++), one for Android (Java). Fabric moves the shadow tree, prop diffing, and view-update logic into a single C++ library consumed by both platforms. One source of truth, two platform-specific binding layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrent rendering support.&lt;/strong&gt; Fabric is the first React Native renderer that genuinely supports React 18 concurrent features — Suspense, transitions, &lt;code&gt;useDeferredValue&lt;/code&gt; — because the renderer no longer assumes a single JS→native commit per frame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static view configs.&lt;/strong&gt; View props are declared ahead of time via Codegen rather than discovered at runtime, which means the native side knows the full prop surface of every component before the first render. Faster startup, fewer runtime surprises.&lt;/p&gt;

&lt;p&gt;The practical consequence: animations and gestures that were previously only feasible with native-threaded libraries like Reanimated are now feasible in plain React. Reanimated still wins for truly complex cases, but the baseline moved up.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Image suggestion: diagram showing legacy bridge (async JSON) vs. JSI (synchronous C++ calls).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TurboModules and Codegen: the end of the native module soap opera
&lt;/h2&gt;

&lt;p&gt;If you've been in the React Native ecosystem long enough, you've written a native module. You've also probably written a Podfile fix, an &lt;code&gt;android/settings.gradle&lt;/code&gt; tweak, and a &lt;code&gt;jest.setup.js&lt;/code&gt; mock to make it work in all four environments.&lt;/p&gt;

&lt;p&gt;TurboModules clean this up significantly. The flow in 2026 is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Author a TypeScript spec (&lt;code&gt;NativeMyModule.ts&lt;/code&gt;) with the module's interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codegen&lt;/strong&gt; reads the spec at build time and generates:

&lt;ul&gt;
&lt;li&gt;Platform-specific interface headers (ObjC++ protocol, Java interface + Kotlin shims)&lt;/li&gt;
&lt;li&gt;A JSI binding layer&lt;/li&gt;
&lt;li&gt;A JavaScript wrapper with real types&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The module is &lt;strong&gt;lazy-loaded&lt;/strong&gt; on first access — it isn't initialized at app launch unless you touch it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The three practical wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lazy loading&lt;/strong&gt; cuts cold start time, especially on apps with many native modules installed but used rarely (think: a payments SDK that's only touched on the checkout screen).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type safety&lt;/strong&gt; across the JS/native boundary stops a long-standing class of bugs where a native method signature drifted from its JS wrapper and nobody noticed until runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No more manual linking.&lt;/strong&gt; Expo-style autolinking is now the default assumption. Most libraries no longer ship linking instructions because there's nothing to link manually.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TurboModules sit next to another new concept, &lt;strong&gt;Fabric Native Components&lt;/strong&gt;, which are the equivalent for native UI primitives (think &lt;code&gt;UIView&lt;/code&gt; subclasses or custom Android &lt;code&gt;ViewGroup&lt;/code&gt;s). Together they cover the full "I need to call something written in Swift / Kotlin / C++" surface of React Native.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bridgeless: the mode that doesn't instantiate the bridge
&lt;/h2&gt;

&lt;p&gt;Here's where the semantics get fuzzy for people new to this. "New Architecture" and "bridgeless" are often used interchangeably, but they're not the same thing.&lt;/p&gt;

&lt;p&gt;For a while, apps could run on the New Architecture while still booting the legacy bridge for backwards compatibility — Fabric for rendering, TurboModules for new modules, but the old &lt;code&gt;RCTBridge&lt;/code&gt; was still created to host anything that hadn't been migrated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bridgeless mode&lt;/strong&gt; removes even that. The entire bridge class never gets instantiated. The runtime boots directly into a JSI-only world. On Android, that means no &lt;code&gt;CatalystInstance&lt;/code&gt;. On iOS, no &lt;code&gt;RCTBridge&lt;/code&gt;. The surface area for lifecycle bugs collapses, and startup gets measurably faster.&lt;/p&gt;

&lt;p&gt;Bridgeless shipped as default in React Native 0.74 (experimental), stabilized in 0.76, and from 0.82 is the only option. If you have &lt;code&gt;legacy&lt;/code&gt; anything in your &lt;code&gt;Podfile&lt;/code&gt; or &lt;code&gt;AndroidManifest.xml&lt;/code&gt; in 2026, it's dead code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's locked in from 0.76 → 0.82 → beyond
&lt;/h2&gt;

&lt;p&gt;The timeline matters because the ecosystem's assumptions keep shifting:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;New Arch status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.68&lt;/td&gt;
&lt;td&gt;Mar 2022&lt;/td&gt;
&lt;td&gt;New Architecture first available, opt-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.74&lt;/td&gt;
&lt;td&gt;Apr 2024&lt;/td&gt;
&lt;td&gt;Bridgeless shipped (experimental)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;Aug 2024&lt;/td&gt;
&lt;td&gt;Codegen improvements, library migration wave&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;0.76&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Oct 2024&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;New Architecture enabled by default; &lt;code&gt;newArchEnabled&lt;/code&gt; flag introduced for opt-out&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.78&lt;/td&gt;
&lt;td&gt;Mar 2025&lt;/td&gt;
&lt;td&gt;React 19 support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;td&gt;Aug 2025&lt;/td&gt;
&lt;td&gt;Final version supporting legacy architecture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;0.82&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Oct 2025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Legacy architecture removed; &lt;code&gt;newArchEnabled=false&lt;/code&gt; no longer honored&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.83+&lt;/td&gt;
&lt;td&gt;2026&lt;/td&gt;
&lt;td&gt;Deeper C++ core work, future-proofing for React Native in server-driven UI contexts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Expo tracked this closely: Expo SDK 52 enabled New Arch by default, SDK 53 made every expo-* package New Arch-compatible, and SDK 55 (which bundles React Native 0.83) removed the &lt;code&gt;newArchEnabled&lt;/code&gt; field from &lt;code&gt;app.json&lt;/code&gt; entirely.&lt;/p&gt;

&lt;p&gt;The ecosystem story mirrors this. React Native Directory's compatibility list crossed 95% New Arch-ready in mid-2025. The long-tail holdouts in 2026 are mostly abandoned community modules — not actively maintained libraries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What migrating actually feels like (the honest version)
&lt;/h2&gt;

&lt;p&gt;Most of the "migration guides" published in 2024 vastly overstated how hard this is. In 2026, for a typical app, the migration is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bump to React Native 0.82 (or Expo SDK 55).&lt;/li&gt;
&lt;li&gt;Run the Expo doctor / React Native upgrade helper.&lt;/li&gt;
&lt;li&gt;Fix anywhere between 0 and 4 library incompatibilities.&lt;/li&gt;
&lt;li&gt;Ship.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The hard migration work was done by Meta, by Expo, and by library maintainers — not by app teams.&lt;/p&gt;

&lt;p&gt;Where it genuinely gets painful is a narrow set of cases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom native modules written against the old API.&lt;/strong&gt; If your team wrote &lt;code&gt;RCT_EXPORT_METHOD&lt;/code&gt;-style modules in 2020 and never touched them, you'll need to port them to the TurboModule spec. The ported code is cleaner, but the port isn't automatic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct references to &lt;code&gt;UIManager&lt;/code&gt;.&lt;/strong&gt; Legacy code that reached directly into the view manager (&lt;code&gt;UIManager.measure&lt;/code&gt;, &lt;code&gt;UIManager.dispatchViewManagerCommand&lt;/code&gt;) is one of the most common breakage points. Fabric exposes most of this via refs and imperative handles instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Libraries with native UI primitives that haven't been ported to Fabric Native Components.&lt;/strong&gt; Common offenders: anything built on top of legacy &lt;code&gt;UIViewController&lt;/code&gt;-container patterns, some charting libraries, and a handful of older map and video SDKs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third-party SDKs that ship their own React Native shims.&lt;/strong&gt; Analytics SDKs, in particular, often shipped RN wrappers that assumed the bridge existed. Most have been updated; a few still haven't. &lt;a href="https://reactnative.dev/architecture/landing-page" rel="noopener noreferrer"&gt;See the official New Architecture status page&lt;/a&gt; for the up-to-date compatibility picture.&lt;/p&gt;

&lt;p&gt;The rule of thumb from teams that migrated large apps in 2025: budget two weeks for a mature app and one afternoon for anything built in the last 18 months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the New Architecture means for performance (realistically)
&lt;/h2&gt;

&lt;p&gt;A fair question for anyone evaluating React Native in 2026: &lt;strong&gt;does bridgeless mode actually make my app faster?&lt;/strong&gt; Based on reports from teams that migrated and published numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Startup time&lt;/strong&gt;: 10–30% improvement on cold start is typical. Apps with many lazy-loadable TurboModules see more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrolling and gestures&lt;/strong&gt;: measurable improvements, especially on lower-end Android devices where the bridge was the bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory&lt;/strong&gt;: lower baseline, because the two parallel renderer implementations are gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jank&lt;/strong&gt;: fewer "frame drops under load" scenarios because layout no longer waits for the JS thread to respond across an async boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the New Architecture does &lt;em&gt;not&lt;/em&gt; do: make a badly architected app fast. If your bottleneck is rendering 10,000 items in a &lt;code&gt;ScrollView&lt;/code&gt;, Fabric helps but doesn't fix it. The usual advice — virtualized lists, memoization, native-driven animations — still applies.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Image suggestion: side-by-side iOS + Android devices running a React Native app — represents the shared C++ core now powering both.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for AI code generation
&lt;/h2&gt;

&lt;p&gt;Here's the angle most of the coverage of the New Architecture has missed in 2026: the dominant React Native code generator in 2026 isn't a human — it's an AI model.&lt;/p&gt;

&lt;p&gt;Tools like &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-new-architecture-2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; now generate full React Native + Expo apps from natural-language prompts, and the New Architecture quietly made that process significantly cleaner. Three reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript specs for native modules are now the source of truth.&lt;/strong&gt; An AI generating code against a TurboModule spec is generating against a typed interface, not reading tribal knowledge from a six-year-old native module README. The spec files are small, structured, and ideal context for a language model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous layout removes an entire class of hallucinated code.&lt;/strong&gt; AI models historically generated a lot of &lt;code&gt;setTimeout(() =&amp;gt; measure(ref), 0)&lt;/code&gt; patterns because that's what the training data looked like. Under Fabric, that code is both wrong and unnecessary — layout is just synchronous. Modern generation targeting RN 0.82+ emits cleaner idioms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fabric's static view configs are discoverable.&lt;/strong&gt; Codegen produces machine-readable prop definitions for every component. An AI generator with access to those specs knows exactly which props a given component accepts, with their types, without guessing from docs.&lt;/p&gt;

&lt;p&gt;If you're building apps with an &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-new-architecture-2026" rel="noopener noreferrer"&gt;AI mobile app builder&lt;/a&gt; in 2026, the New Architecture is one of the reasons the generated code is better than the equivalent generation would have been two years ago. The platform underneath is simpler, more typed, and more predictable — all qualities LLMs reward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What developers should actually do in 2026
&lt;/h2&gt;

&lt;p&gt;A short, honest checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If you're starting a new app&lt;/strong&gt;: don't think about this. Use the current Expo SDK or create-react-native-app. You're on the New Architecture by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you're on 0.74 or 0.75 and shipping&lt;/strong&gt;: upgrade when you can. There's no rush, but every month of drift makes the eventual bump larger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you're on 0.70 or older&lt;/strong&gt;: this is now a real project. Budget time for library audits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you maintain a React Native library&lt;/strong&gt;: if it isn't New Arch-ready yet in 2026, it's going to lose adoption fast. The ecosystem has moved on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you're deciding between React Native and native in 2026&lt;/strong&gt;: the main historical performance argument against React Native is substantially weaker now. Evaluate on team skills and library ecosystem, not on "the bridge is slow" — because the bridge is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader point: React Native in 2026 is a materially different framework than React Native in 2022. The runtime underneath has been rebuilt. The developer-facing surface looks the same, but the ceiling has moved up, and the cost of writing fast cross-platform mobile apps keeps dropping.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is the React Native New Architecture backwards-compatible?
&lt;/h3&gt;

&lt;p&gt;Partially. Your JavaScript code is almost always compatible. Your native modules may need porting if they used the legacy &lt;code&gt;RCT_EXPORT_METHOD&lt;/code&gt; or Android &lt;code&gt;ReactMethod&lt;/code&gt; APIs directly. Starting from React Native 0.82, there is no backwards-compatible fallback — the legacy architecture is removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to rewrite my app to use Fabric?
&lt;/h3&gt;

&lt;p&gt;No. For almost all apps, the migration is a version bump plus library updates. Your component code, state management, and business logic don't change. Only direct interactions with legacy native APIs (&lt;code&gt;UIManager&lt;/code&gt;, custom bridge modules, old gesture handlers) need touching.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is bridgeless mode the same as the New Architecture?
&lt;/h3&gt;

&lt;p&gt;They overlap but aren't identical. The New Architecture is the full set of changes — JSI, Fabric, TurboModules, Codegen. Bridgeless mode specifically refers to the runtime mode in which the legacy &lt;code&gt;RCTBridge&lt;/code&gt; / &lt;code&gt;CatalystInstance&lt;/code&gt; is never instantiated. From 0.82 onward, both are the only option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will Hermes still work with the New Architecture?
&lt;/h3&gt;

&lt;p&gt;Yes. Hermes is the recommended JavaScript engine and is deeply integrated with JSI. If anything, Hermes benefits most from the New Architecture because its startup-time advantages compound with TurboModule lazy loading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Expo support the New Architecture?
&lt;/h3&gt;

&lt;p&gt;Yes. Expo SDK 52 enabled it by default, SDK 53 made every expo-* package compatible, and SDK 55 (bundling RN 0.83) removed the opt-out field entirely. &lt;a href="https://docs.expo.dev/guides/new-architecture/" rel="noopener noreferrer"&gt;See the Expo guide to the New Architecture&lt;/a&gt; for details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build on the New Architecture without thinking about it
&lt;/h2&gt;

&lt;p&gt;The best thing about the React Native New Architecture in 2026 is that for most app teams, it's invisible. The platform got faster, cleaner, and more typed, and you didn't have to do anything to get those benefits.&lt;/p&gt;

&lt;p&gt;If you want to see what shipping on this modern stack looks like without wiring any of it up yourself, &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-new-architecture-2026" rel="noopener noreferrer"&gt;try describing your app idea in plain English&lt;/a&gt;. RapidNative generates React Native + Expo apps that already target the New Architecture, Fabric, and bridgeless mode by default — because that's the only React Native there is now.&lt;/p&gt;

&lt;p&gt;Related reading: &lt;a href="https://www.rapidnative.com/blogs/react-native-performance-optimization-2026-playbook?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-new-architecture-2026" rel="noopener noreferrer"&gt;React Native Performance Optimization: 2026 Playbook&lt;/a&gt; · &lt;a href="https://www.rapidnative.com/blogs/react-native-vs-native-development?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-new-architecture-2026" rel="noopener noreferrer"&gt;React Native vs Native Development&lt;/a&gt; · &lt;a href="https://www.rapidnative.com/blogs/react-native-app-best-practices?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-new-architecture-2026" rel="noopener noreferrer"&gt;React Native App Best Practices&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Build a Video Calling App with React Native and WebRTC</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:43:06 +0000</pubDate>
      <link>https://dev.to/mason_roy/how-to-build-a-video-calling-app-with-react-native-and-webrtc-214k</link>
      <guid>https://dev.to/mason_roy/how-to-build-a-video-calling-app-with-react-native-and-webrtc-214k</guid>
      <description>&lt;p&gt;A FaceTime-quality call looks like one feature. Underneath, it is five systems that have to work in perfect sequence — camera capture, codec negotiation, NAT traversal, a signaling channel, and connection repair. This guide walks through building a React Native WebRTC video calling app end to end: real code, honest architectural tradeoffs, and the parts most tutorials skip. By the end you will have a working 1-to-1 call, a signaling server, and a clear path to scale beyond two participants. For the surrounding screens — login, contacts, call history — &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates production-grade React Native code from a prompt, so you can focus the hard engineering budget on the call itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1587620962725-abab7fe55159%3Fw%3D800" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1587620962725-abab7fe55159%3Fw%3D800" alt="React Native developer coding video calling app on laptop" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;React Native and WebRTC together ship real-time video to iOS and Android from a single codebase — Photo by Christopher Gower on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What You Need to Build Video Calling in React Native
&lt;/h2&gt;

&lt;p&gt;A React Native WebRTC video calling app needs four pieces: the &lt;code&gt;react-native-webrtc&lt;/code&gt; library to expose native video APIs, a signaling server to exchange SDP offers and ICE candidates, STUN/TURN servers for NAT traversal, and camera and microphone permissions configured for both iOS and Android. Expo users must use the bare workflow or EAS Build with the Expo config plugin — WebRTC does not run in Expo Go.&lt;/p&gt;

&lt;p&gt;That is the 60-second answer. The rest of this guide is why each piece exists, how they talk to each other, and how to debug the specific failure modes that will show up when you plug it all together.&lt;/p&gt;
&lt;h2&gt;
  
  
  WebRTC vs Managed SDKs: The Choice That Shapes Everything
&lt;/h2&gt;

&lt;p&gt;Before you write a line of code, choose a lane. WebRTC is a protocol, not a product. You can either own the whole stack or buy pieces of it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What You Build&lt;/th&gt;
&lt;th&gt;What You Pay For&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Raw WebRTC + self-hosted signaling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your signaling server, STUN/TURN infrastructure, call state logic&lt;/td&gt;
&lt;td&gt;Infra, debugging, TURN bandwidth&lt;/td&gt;
&lt;td&gt;Learning, custom protocols, low volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebRTC + managed TURN (Twilio, Xirsys)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Signaling, call logic&lt;/td&gt;
&lt;td&gt;TURN bandwidth per GB&lt;/td&gt;
&lt;td&gt;Production 1-to-1 at modest scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Managed SDK (Agora, Daily, Twilio Video, Dyte)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;UI integration&lt;/td&gt;
&lt;td&gt;Per-minute call fees&lt;/td&gt;
&lt;td&gt;Teams who want to ship this week&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern I see most often: people start with raw WebRTC to learn, then move to a managed SDK the moment they need group calls, recording, or background noise suppression. There is no shame in that — building an SFU (Selective Forwarding Unit) that scales to 50 participants is a product in itself. For a 1-to-1 call app, raw WebRTC is the right call. For a Zoom clone, buy the stack.&lt;/p&gt;

&lt;p&gt;This tutorial covers the raw-WebRTC path because understanding it makes every other option legible.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Signaling Dance: SDP, ICE, and Why Two Phones Can't Just Talk
&lt;/h2&gt;

&lt;p&gt;WebRTC uses peer-to-peer video, meaning your two phones exchange media directly without routing it through your server. But before they can, they need to find each other across the public internet, agree on video codecs, and punch holes through their NATs. The signaling server mediates that negotiation. It does not touch any video or audio.&lt;/p&gt;

&lt;p&gt;Here is the choreography for a 1-to-1 call:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Caller creates a &lt;code&gt;RTCPeerConnection&lt;/code&gt;&lt;/strong&gt; and attaches their local camera and microphone stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caller generates an SDP offer&lt;/strong&gt; — a text blob describing codecs, resolutions, and media capabilities — and sends it to the callee through the signaling server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Callee creates its own &lt;code&gt;RTCPeerConnection&lt;/code&gt;&lt;/strong&gt;, sets the offer as the remote description, and generates an SDP answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both peers gather ICE candidates&lt;/strong&gt; — network paths the other side might use to reach them — and swap them through the signaling server (this is "trickle ICE").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browsers try candidates in priority order&lt;/strong&gt; (host, srflx via STUN, relay via TURN) until one works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A direct peer connection is established.&lt;/strong&gt; Signaling is no longer needed. Video flows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of those steps silently fails, the call appears to connect, both users see "ringing," and then nothing happens. Debugging WebRTC is 80% logging each phase of this dance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1558494949-ef010cbdcc31%3Fw%3D800" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1558494949-ef010cbdcc31%3Fw%3D800" alt="Network diagram concept showing peer-to-peer connections" width="800" height="449"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;WebRTC peer connection flow — the signaling server negotiates, but media flows directly between devices — Photo by Taylor Vick on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting Up React Native WebRTC
&lt;/h2&gt;

&lt;p&gt;Install the library and native dependencies first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Bare React Native project&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;react-native-webrtc
&lt;span class="nb"&gt;cd &lt;/span&gt;ios &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pod &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ..

&lt;span class="c"&gt;# Expo projects (bare workflow or EAS Build only)&lt;/span&gt;
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;react-native-webrtc @config-plugins/react-native-webrtc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Expo projects, add the config plugin to &lt;code&gt;app.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"@config-plugins/react-native-webrtc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"cameraPermission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow $(PRODUCT_NAME) to access your camera for video calls"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"microphonePermission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow $(PRODUCT_NAME) to access your microphone for calls"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run &lt;code&gt;npx expo prebuild&lt;/code&gt; and &lt;code&gt;eas build --profile development&lt;/code&gt;. &lt;strong&gt;WebRTC will crash in Expo Go&lt;/strong&gt; — that is not a bug, Expo Go cannot load custom native modules.&lt;/p&gt;

&lt;p&gt;For bare React Native, add permissions to &lt;code&gt;ios/&amp;lt;YourApp&amp;gt;/Info.plist&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSCameraUsageDescription&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;We need the camera for video calls&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSMicrophoneUsageDescription&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;We need the microphone for calls&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in &lt;code&gt;android/app/src/main/AndroidManifest.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.CAMERA"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.RECORD_AUDIO"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.MODIFY_AUDIO_SETTINGS"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.ACCESS_NETWORK_STATE"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.CHANGE_NETWORK_STATE"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.INTERNET"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android, you also need to request runtime permissions before accessing the camera. React Native's &lt;code&gt;PermissionsAndroid&lt;/code&gt; handles this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing the Local Camera Stream
&lt;/h2&gt;

&lt;p&gt;With permissions in place, grab the local media stream using &lt;code&gt;mediaDevices.getUserMedia&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MediaStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RTCView&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-webrtc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CallScreen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLocalStream&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MediaStream&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startLocalStream&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;480&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;frameRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// front camera&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;setLocalStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;startLocalStream&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RTCView&lt;/span&gt;
      &lt;span class="nx"&gt;streamURL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toURL&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
      &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
      &lt;span class="nx"&gt;objectFit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="nx"&gt;mirror&lt;/span&gt;
    &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RTCView&lt;/code&gt; is the native component that renders a &lt;code&gt;MediaStream&lt;/code&gt;. It accepts a &lt;code&gt;streamURL&lt;/code&gt; (not the stream object directly), &lt;code&gt;objectFit&lt;/code&gt; for aspect handling, and &lt;code&gt;mirror&lt;/code&gt; for front-camera previews. Clean up tracks in the unmount return — forgetting this leaves the camera light on, which users notice fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Peer Connection
&lt;/h2&gt;

&lt;p&gt;Once you have a local stream, create the &lt;code&gt;RTCPeerConnection&lt;/code&gt; and attach it. Here is the caller side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;RTCSessionDescription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;RTCIceCandidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-webrtc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;configuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stun:stun.l.google.com:19302&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turn:your-turn-server.com:3478&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MediaStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signaling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SignalingClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Attach local tracks&lt;/span&gt;
  &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Remote stream handler&lt;/span&gt;
  &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;track&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;remoteStream&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streams&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Pass remoteStream up to render in another &amp;lt;RTCView /&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Trickle ICE candidates to the signaling channel&lt;/span&gt;
  &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;icecandidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;signaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ice-candidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Create and send the SDP offer&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOffer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;offerToReceiveVideo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;offerToReceiveAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;signaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Handle incoming answer and ICE from callee&lt;/span&gt;
  &lt;span class="nx"&gt;signaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRemoteDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCSessionDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;signaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ice-candidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addIceCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCIceCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The callee is a mirror image: receive the offer, create its own &lt;code&gt;RTCPeerConnection&lt;/code&gt;, &lt;code&gt;setRemoteDescription(offer)&lt;/code&gt;, create an answer, &lt;code&gt;setLocalDescription(answer)&lt;/code&gt;, send the answer back. Both sides exchange ICE candidates as they appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Signaling Server
&lt;/h2&gt;

&lt;p&gt;Any transport that can relay JSON between two clients works — WebSocket, Socket.io, Firebase Realtime Database, Supabase Realtime, or your own channel. For this guide, a minimal Socket.io server is clearest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server/signaling.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;socket.io&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// roomId -&amp;gt; Set of socket IDs&lt;/span&gt;

&lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;connection&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;join&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sockets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;peers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disconnect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;peer-left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server never sees SDP or ICE content — it is a dumb relay. That is deliberate: it keeps signaling cheap to scale and makes end-to-end encryption straightforward (WebRTC's SRTP handles media encryption automatically).&lt;/p&gt;

&lt;p&gt;In production you will want authentication (JWT on the socket handshake), room access control, and persistence of pending signals for callees who are offline when the ringer fires. For push-based call ringing on iOS, you need PushKit and CallKit; on Android, FCM with high-priority messages and ConnectionService. That is a separate rabbit hole worth its own guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  STUN, TURN, and Why Your Call Fails on Hotel Wi-Fi
&lt;/h2&gt;

&lt;p&gt;STUN servers help a peer discover its public IP so another peer can reach it directly. Google's public STUN (&lt;code&gt;stun:stun.l.google.com:19302&lt;/code&gt;) is free and fine for development. In production, roughly 15% of users sit behind symmetric NATs where STUN alone cannot establish a path — hotel Wi-Fi, corporate networks, mobile carrier CGNAT. For those, you need a TURN server that &lt;strong&gt;relays media through itself&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;TURN is bandwidth-hungry and therefore expensive. Options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-host coturn&lt;/strong&gt; — the open-source TURN server. Cheapest at scale, requires DevOps capacity. &lt;a href="https://github.com/coturn/coturn" rel="noopener noreferrer"&gt;coturn on GitHub&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed TURN (Twilio Network Traversal, Xirsys, Metered)&lt;/strong&gt; — a few dollars per GB. Rolls into production in minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid&lt;/strong&gt; — self-hosted for most traffic, Twilio as fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb: a 1-to-1 call at 720p/30fps uses ~1.5 Mbps per direction. One hour of fully-relayed call ≈ 1.35 GB of TURN bandwidth. Price your plan accordingly.&lt;/p&gt;

&lt;p&gt;Set up your &lt;code&gt;iceServers&lt;/code&gt; with both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;configuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stun:stun.l.google.com:19302&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turn:turn.example.com:3478&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turns:turn.example.com:5349&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timeLimitedUsername&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timeLimitedCredential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;iceCandidatePoolSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rotate TURN credentials — never hard-code long-lived ones in your mobile app. The HMAC-based scheme in &lt;a href="https://datatracker.ietf.org/doc/html/rfc7635" rel="noopener noreferrer"&gt;RFC 7635&lt;/a&gt; is the pattern most managed providers use.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1588196749597-9ff075ee6b5b%3Fw%3D800" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1588196749597-9ff075ee6b5b%3Fw%3D800" alt="Mobile phone showing video call interface" width="800" height="600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A production video call combines WebRTC's protocol, platform-native rendering, and robust signaling — Photo by Yura Fresh on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Rendering the Remote Stream
&lt;/h2&gt;

&lt;p&gt;When the &lt;code&gt;track&lt;/code&gt; event fires, you have a remote stream. Pass it up and render it in a second &lt;code&gt;RTCView&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;View&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RTCView&lt;/span&gt;
    &lt;span class="nx"&gt;streamURL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;remoteStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toURL&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteVideo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;objectFit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RTCView&lt;/span&gt;
    &lt;span class="nx"&gt;streamURL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toURL&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pictureInPicture&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;objectFit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="nx"&gt;mirror&lt;/span&gt;
    &lt;span class="nx"&gt;zOrder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// Android: render above remote view&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/View&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;zOrder={1}&lt;/code&gt; is Android-specific — without it, the local picture-in-picture renders behind the full-screen remote view. iOS orders by component tree order, so it just works there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Call Controls: Mute, Switch Camera, Hang Up
&lt;/h2&gt;

&lt;p&gt;These are small methods on the stream and track objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mute mic&lt;/span&gt;
&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Toggle video&lt;/span&gt;
&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Switch between front and back camera&lt;/span&gt;
&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;_switchCamera&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// End the call&lt;/span&gt;
&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;signaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bye&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;enabled = false&lt;/code&gt; on an audio track is cheaper than removing it entirely and does not renegotiate SDP. &lt;code&gt;_switchCamera()&lt;/code&gt; is a &lt;code&gt;react-native-webrtc&lt;/code&gt; extension — on web WebRTC you would renegotiate with a new &lt;code&gt;getUserMedia&lt;/code&gt; call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Breaks in Production (and How to Debug It)
&lt;/h2&gt;

&lt;p&gt;Three failure modes account for most "it works on my simulator" moments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simulator has no camera.&lt;/strong&gt; iOS Simulator cannot access a real camera, and Android emulators have limited support. Test video on physical devices. Audio can be simulated on both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App Store review requires permission strings in English.&lt;/strong&gt; Your &lt;code&gt;NSCameraUsageDescription&lt;/code&gt; must clearly explain &lt;em&gt;why&lt;/em&gt; you need the camera. "App needs camera" gets rejected. "Needed for video calling with contacts" passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ICE connection stalls at &lt;code&gt;checking&lt;/code&gt;.&lt;/strong&gt; The ICE state goes &lt;code&gt;new → checking → connected → completed&lt;/code&gt;. Stuck at &lt;code&gt;checking&lt;/code&gt; means candidates were exchanged but none worked. Nine times out of ten this is a missing or misconfigured TURN server. Attach a listener:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iceconnectionstatechange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ICE state:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iceConnectionState&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Background audio stops.&lt;/strong&gt; On iOS, add &lt;code&gt;audio&lt;/code&gt; to &lt;code&gt;UIBackgroundModes&lt;/code&gt; in Info.plist. On Android, run a foreground service during active calls — otherwise the OS will suspend your process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One side sees the other but not vice versa.&lt;/strong&gt; Almost always an SDP direction mismatch — one peer created an offer with &lt;code&gt;offerToReceiveVideo: false&lt;/code&gt; or forgot to &lt;code&gt;addTrack&lt;/code&gt; before &lt;code&gt;createOffer&lt;/code&gt;. Log the SDP on both sides; look for &lt;code&gt;a=sendrecv&lt;/code&gt; on each media line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling Beyond 1-to-1: SFU, MCU, or Mesh?
&lt;/h2&gt;

&lt;p&gt;Peer-to-peer scales badly. Each additional participant adds N-1 upstreams per device — a 4-person call uploads your video three times. By 5 participants you have saturated most mobile uplinks.&lt;/p&gt;

&lt;p&gt;Three paths forward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mesh (what we built)&lt;/strong&gt; — works up to 3-4 participants on good networks. No server-side media infra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SFU (Selective Forwarding Unit)&lt;/strong&gt; — each client sends one upload; the server forwards streams to everyone else. Scales to ~50 participants. Open-source options: &lt;a href="https://mediasoup.org/" rel="noopener noreferrer"&gt;mediasoup&lt;/a&gt;, &lt;a href="https://livekit.io/" rel="noopener noreferrer"&gt;LiveKit&lt;/a&gt;, &lt;a href="https://janus.conf.meetecho.com/" rel="noopener noreferrer"&gt;Janus&lt;/a&gt;. Managed: Daily, Dyte, 100ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCU (Multipoint Control Unit)&lt;/strong&gt; — server mixes streams into one composite video. Lowest client CPU, highest server cost. Rare in modern stacks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your product needs group calls, start with LiveKit or mediasoup. Writing an SFU from scratch is a six-month detour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping the Rest of the App
&lt;/h2&gt;

&lt;p&gt;The hard part of a calling app is the call. The rest — authentication, contacts, call history, settings, notifications — is standard mobile UI. That is where &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; saves weeks. Describe the screens in plain English and get working React Native + Expo code with real state, Supabase-backed data, and a live preview you can test on your phone via QR code. Point and edit any element, iterate in minutes, and export the full project when you are ready to drop in your WebRTC code. Teams typically ship &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;85% faster than hand-coding&lt;/a&gt; the surrounding surfaces.&lt;/p&gt;

&lt;p&gt;For deeper reading, see the official &lt;a href="https://react-native-webrtc.discourse.group/" rel="noopener noreferrer"&gt;react-native-webrtc documentation&lt;/a&gt; and the &lt;a href="https://www.w3.org/TR/webrtc/" rel="noopener noreferrer"&gt;WebRTC specification at the W3C&lt;/a&gt;. For related mobile patterns, our guides on &lt;a href="https://www.rapidnative.com/blogs/push-notifications-in-react-native?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;push notifications in React Native&lt;/a&gt; and &lt;a href="https://www.rapidnative.com/blogs/how-we-built-the-real-time-sync-layer-for-team-app-building?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;how we built real-time collaboration&lt;/a&gt; cover adjacent real-time infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does React Native WebRTC work with Expo Go?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;react-native-webrtc&lt;/code&gt; is a native module and cannot run in Expo Go. You need the bare workflow or Expo's EAS Build with the &lt;code&gt;@config-plugins/react-native-webrtc&lt;/code&gt; plugin. After adding the plugin, run &lt;code&gt;expo prebuild&lt;/code&gt; and build a development client — it will then work exactly like a bare React Native project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I really need a TURN server for a video calling app?
&lt;/h3&gt;

&lt;p&gt;Yes, if you want calls to work for everyone. Roughly 15% of users sit behind symmetric NATs or firewalls where direct peer-to-peer connections fail. A TURN server relays their traffic. In development, Google's public STUN is enough; in production, pair STUN with a managed TURN provider (Twilio, Xirsys, Metered) or self-hosted coturn.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between STUN and TURN in WebRTC?
&lt;/h3&gt;

&lt;p&gt;STUN helps a peer discover its public IP so another peer can connect directly. TURN relays media through a server when direct connections fail. STUN is lightweight and nearly free; TURN consumes significant bandwidth and costs money. Every production WebRTC app configures both — STUN as the preferred path, TURN as fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the Rest of Your App in Minutes
&lt;/h2&gt;

&lt;p&gt;WebRTC is deep, specific engineering. The screens around it should not be. Describe your calling app in plain words with &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, export clean React Native code, and plug in the WebRTC flow from this guide. Start free with 20 credits — no card required.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>rapidnative</category>
      <category>react</category>
    </item>
    <item>
      <title>Your React Native Login Works. Your Session Doesn't.</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:41:03 +0000</pubDate>
      <link>https://dev.to/mason_roy/your-react-native-login-works-your-session-doesnt-4nbe</link>
      <guid>https://dev.to/mason_roy/your-react-native-login-works-your-session-doesnt-4nbe</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Login is the easy 10%. The bugs live in &lt;em&gt;session&lt;/em&gt; handling: refresh, offline, reinstall, cold start.&lt;/li&gt;
&lt;li&gt;Five requests hitting a 401 at once = five refresh calls = rotated tokens invalidating each other = random logouts. Fix: a single-flight refresh promise.&lt;/li&gt;
&lt;li&gt;A failed &lt;code&gt;fetch&lt;/code&gt; (no signal) is not a failed refresh. Never log the user out on a network error.&lt;/li&gt;
&lt;li&gt;iOS Keychain survives app uninstall. Your "fresh install" can boot into someone's old session. Wipe SecureStore on first launch.&lt;/li&gt;
&lt;li&gt;Gate routes with &lt;code&gt;Stack.Protected&lt;/code&gt; and keep the splash screen up until the session is resolved — no login-screen flash.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Every auth tutorial ends at the same place: user taps "Sign in", you get a token, you navigate to the home screen. Ship it.&lt;/p&gt;

&lt;p&gt;Then the bug reports start. "App logged me out on the train." "I reinstalled and it showed my old account." "It flashes the login screen for a second every time I open it." None of these are login bugs. They're session bugs, and they only show up once real people use the app on real phones with real network conditions.&lt;/p&gt;

&lt;p&gt;Here are the four I've hit most, with the code I now drop into every Expo project.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The 401 refresh storm
&lt;/h2&gt;

&lt;p&gt;Your home screen mounts and fires five requests in parallel. The access token expired an hour ago. All five come back &lt;code&gt;401&lt;/code&gt;. Your interceptor — written the obvious way — calls &lt;code&gt;/auth/refresh&lt;/code&gt; five times.&lt;/p&gt;

&lt;p&gt;If your backend rotates refresh tokens (it should), call #1 succeeds and invalidates the old refresh token. Calls #2–#5 send the now-dead token, fail, and your error handler logs the user out. The user did nothing wrong. They opened the app.&lt;/p&gt;

&lt;p&gt;The fix is boring: only ever have &lt;strong&gt;one&lt;/strong&gt; refresh in flight, and make everyone else wait on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/auth/refresh.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-secure-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REFRESH_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth.refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// memory only — see below&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SessionExpiredError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;inflight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;doRefresh&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;inflight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;doRefresh&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;REFRESH_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SessionExpiredError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/auth/refresh`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refresh&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// The server *answered* and said no → the session is genuinely dead.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;clearSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SessionExpiredError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Refresh failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// rotation: persist the new one before anyone else needs it&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;REFRESH_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;clearSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;REFRESH_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a fetch wrapper that retries &lt;strong&gt;once&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/auth/authFetch.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshAccessToken&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RequestInit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// all 5 callers share this&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a second 401 is a real authorization error — don't loop&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five parallel 401s now produce exactly one refresh call. The other four await the same promise and retry with the new token.&lt;/p&gt;

&lt;p&gt;Opinion: keep the &lt;strong&gt;access&lt;/strong&gt; token in memory only. It's short-lived, and re-deriving it from the refresh token on cold start costs one request. That leaves exactly one secret on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Logging people out because the train went into a tunnel
&lt;/h2&gt;

&lt;p&gt;Look at &lt;code&gt;doRefresh&lt;/code&gt; again. There are three outcomes, and most code treats two of them the same:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fetch&lt;/code&gt; throws&lt;/td&gt;
&lt;td&gt;No network. You know nothing.&lt;/td&gt;
&lt;td&gt;Keep the session. Retry later.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server returns 401/403&lt;/td&gt;
&lt;td&gt;Refresh token revoked or expired&lt;/td&gt;
&lt;td&gt;Clear session, go to sign-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server returns 5xx&lt;/td&gt;
&lt;td&gt;Your backend is having a day&lt;/td&gt;
&lt;td&gt;Keep the session. Surface an error.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The common bug is a &lt;code&gt;try/catch&lt;/code&gt; around the refresh that logs out on &lt;em&gt;any&lt;/em&gt; exception. That turns "I had one bar of signal" into "I have to find my password." Only an actual answer from the auth server should end a session — which is why the code above only calls &lt;code&gt;clearSession()&lt;/code&gt; on 401/403 and lets everything else bubble up as a normal error.&lt;/p&gt;

&lt;p&gt;Pair it with a foreground check so a stale token gets refreshed when the user comes back, not on their first tap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/auth/useForegroundRefresh.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;refreshAccessToken&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useForegroundRefresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;refreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// network errors are fine here; authFetch will retry on demand&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The ghost session after reinstall
&lt;/h2&gt;

&lt;p&gt;This one surprises people. On iOS, &lt;code&gt;expo-secure-store&lt;/code&gt; is backed by the Keychain, and Keychain items are &lt;strong&gt;not&lt;/strong&gt; removed when the app is uninstalled. Delete the app, reinstall it, and your bootstrap code happily finds the old refresh token and logs straight into the previous account.&lt;/p&gt;

&lt;p&gt;On a personal phone that's mostly confusing. On a shared or resold device, it's someone else's account.&lt;/p&gt;

&lt;p&gt;The fix: &lt;code&gt;AsyncStorage&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; wiped on uninstall, so use it as a "have I launched before?" marker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/auth/firstLaunch.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@react-native-async-storage/async-storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;clearSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INSTALL_FLAG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app.installed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wipeKeychainOnFreshInstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INSTALL_FLAG&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;clearSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                 &lt;span class="c1"&gt;// kill any leftover Keychain token&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INSTALL_FLAG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it before you read anything from SecureStore. While you're in there, two more SecureStore notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It isn't available on web. If you ship a web build, you need a separate storage path.&lt;/li&gt;
&lt;li&gt;Values over ~2KB may be rejected. Store tokens, not user profiles.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The login-screen flash
&lt;/h2&gt;

&lt;p&gt;Cold start, signed-in user, and for 300ms they see the sign-in screen before the app "realizes" they're logged in. Two causes: rendering routes before the session is resolved, and resolving the session with a network call.&lt;/p&gt;

&lt;p&gt;Resolve it &lt;strong&gt;locally&lt;/strong&gt;: if a refresh token exists, treat the user as signed in and let &lt;code&gt;authFetch&lt;/code&gt; refresh lazily. Don't block the first frame on the network — remember section 2, the user might be offline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/auth/SessionProvider.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-secure-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;wipeKeychainOnFreshInstall&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./firstLaunch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signedIn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signedOut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SessionContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SessionProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wipeKeychainOnFreshInstall&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth.refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refresh&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signedIn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signedOut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;})();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SessionContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SessionContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;useSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SessionContext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then gate routes with Expo Router's protected screens and hold the splash until status is known:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Stack&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SplashScreen&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-splash-screen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SessionProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth/SessionProvider&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useForegroundRefresh&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth/useForegroundRefresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;SplashScreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventAutoHideAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootNavigator&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;useForegroundRefresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signedIn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;SplashScreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hideAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// splash is still showing&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt; &lt;span class="na"&gt;screenOptions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headerShown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Protected&lt;/span&gt; &lt;span class="na"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signedIn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Screen&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(app)"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Protected&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Protected&lt;/span&gt; &lt;span class="na"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signedOut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Screen&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sign-in"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Protected&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SessionProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RootNavigator&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SessionProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Stack.Protected&lt;/code&gt; needs a recent Expo Router (SDK 53+). On older versions, the same idea works with a &lt;code&gt;&amp;lt;Redirect /&amp;gt;&lt;/code&gt; in each group's &lt;code&gt;_layout.tsx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One last wire: when &lt;code&gt;authFetch&lt;/code&gt; throws &lt;code&gt;SessionExpiredError&lt;/code&gt;, call &lt;code&gt;setStatus('signedOut')&lt;/code&gt;. The protected guard flips and Expo Router moves the user to sign-in on its own — no manual &lt;code&gt;router.replace&lt;/code&gt; scattered around the codebase.&lt;/p&gt;

&lt;p&gt;If you're spinning up a new app and would rather start from a project where the &lt;code&gt;(app)&lt;/code&gt; / sign-in route groups and &lt;code&gt;_layout.tsx&lt;/code&gt; are already in place, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-auth-session-gotchas" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates React Native + Expo projects from a prompt, so you can spend your time on the session logic above instead of the scaffolding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I run before shipping auth
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Parallel 401s trigger exactly one refresh call (test it: expire the token, open the busiest screen)&lt;/li&gt;
&lt;li&gt;[ ] Airplane mode on cold start → user stays signed in&lt;/li&gt;
&lt;li&gt;[ ] Refresh endpoint returning 500 → user stays signed in&lt;/li&gt;
&lt;li&gt;[ ] Refresh endpoint returning 401 → user lands on sign-in, no crash&lt;/li&gt;
&lt;li&gt;[ ] Uninstall + reinstall on iOS → user is signed out&lt;/li&gt;
&lt;li&gt;[ ] Cold start as a signed-in user → no sign-in screen flash&lt;/li&gt;
&lt;li&gt;[ ] Access token never written to disk&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these take five minutes to test and are invisible until a user files the bug for you.&lt;/p&gt;




&lt;p&gt;What's the weirdest auth bug you've shipped to production? Drop it in the comments — I'm collecting these.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>security</category>
      <category>mobile</category>
    </item>
    <item>
      <title>I Audited 12 React Native Apps for WCAG 2.2. Here's What 11 of Them Got Wrong.</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:56:46 +0000</pubDate>
      <link>https://dev.to/mason_roy/i-audited-12-react-native-apps-for-wcag-22-heres-what-11-of-them-got-wrong-2a7l</link>
      <guid>https://dev.to/mason_roy/i-audited-12-react-native-apps-for-wcag-22-heres-what-11-of-them-got-wrong-2a7l</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I audited 12 production React Native apps against WCAG 2.2 AA. 11 would fail. The median app failed 23 of 55 criteria.&lt;/li&gt;
&lt;li&gt;The DOJ's ADA Title II deadline for state and local government apps is now April 2027 (pushed back a year in April 2026). The EU Accessibility Act is already in force for private-sector apps.&lt;/li&gt;
&lt;li&gt;RN maps almost 1:1 to native accessibility APIs. The problem is nothing forces you to use them.&lt;/li&gt;
&lt;li&gt;Retrofitting a 40-screen app takes 2–4 engineer-weeks. Building with the props from day one costs ~zero.&lt;/li&gt;
&lt;li&gt;Fastest wins: test with VoiceOver/TalkBack, add &lt;code&gt;eslint-plugin-react-native-a11y&lt;/code&gt; as a merge blocker, fix the five patterns below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem isn't React Native
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;accessibilityLabel&lt;/code&gt; becomes a &lt;code&gt;UIAccessibilityLabel&lt;/code&gt; on iOS. Every &lt;code&gt;accessibilityRole&lt;/code&gt; ends up in &lt;code&gt;AccessibilityNodeInfo&lt;/code&gt; on Android. The plumbing is good.&lt;/p&gt;

&lt;p&gt;But you can ship an entire app of styled &lt;code&gt;View&lt;/code&gt;s with &lt;code&gt;onPress&lt;/code&gt; handlers and never set a single accessibility prop. It runs, it looks fine, and VoiceOver users can't see any of it.&lt;/p&gt;

&lt;p&gt;All 12 apps I audited had the same five patterns. Here they are, with fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Custom buttons with no label or role
&lt;/h2&gt;

&lt;p&gt;The classic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ VoiceOver reads nothing useful&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onClose&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CloseIcon&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Announced as "Close, button"&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onClose&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Close"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityHint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Closes the settings panel"&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CloseIcon&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Icon-only buttons are the worst offenders. If there's no visible text, there must be a label.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Tiny touch targets
&lt;/h2&gt;

&lt;p&gt;WCAG 2.2 added Target Size (Minimum) at AA: at least 24×24 CSS px. Apple and Google both recommend going bigger (44pt / 48dp). A 16pt icon fails all of them.&lt;/p&gt;

&lt;p&gt;You don't have to change the visual size, just the hit area:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onLike&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Like"&lt;/span&gt;
  &lt;span class="na"&gt;hitSlop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HeartIcon&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Form errors that are only red text
&lt;/h2&gt;

&lt;p&gt;A sighted user sees the error. A screen reader user hears nothing and wonders why submit didn't work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TextInput&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;EmailField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;announceForAccessibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextInput&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onChangeText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Email address"&lt;/span&gt;
        &lt;span class="na"&gt;accessibilityHint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;accessibilityLiveRegion&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#B00020&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;accessibilityLiveRegion&lt;/code&gt; covers Android; &lt;code&gt;announceForAccessibility&lt;/code&gt; covers iOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Modals that don't trap focus
&lt;/h2&gt;

&lt;p&gt;Users swipe right past your dialog into the screen behind it. Mark the modal content as modal on iOS and hide the background on Android:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;accessibilityViewIsModal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* dialog content */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;importantForAccessibility&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;modalOpen&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-hide-descendants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* screen behind the modal */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React Native's built-in &lt;code&gt;&amp;lt;Modal&amp;gt;&lt;/code&gt; handles most of this for you. Custom bottom sheets and overlays usually don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Text on gradients
&lt;/h2&gt;

&lt;p&gt;Text that passes 4.5:1 contrast at the top of a gradient often fails at the bottom. Check the lightest point the text sits on, not the average. The quick fix is a solid or semi-opaque scrim behind the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop new violations at the PR
&lt;/h2&gt;

&lt;p&gt;None of the fixes above matter if new screens keep shipping without props. Add the lint plugin and make it a required check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-react-native-a11y
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// .eslintrc.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-a11y&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;extends&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plugin:react-native-a11y/all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It won't catch contrast or focus order, but in my audits it would have caught roughly 90% of failures: the missing labels and roles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrofit vs. restart
&lt;/h2&gt;

&lt;p&gt;The retrofit math: a 40-screen app built without accessibility takes 2–4 weeks of one engineer doing nothing else. With 200 screens, every fix needs review, QA and regression testing.&lt;/p&gt;

&lt;p&gt;If your app is under six months old and under thirty screens, restarting on a stack that generates accessible components by default can be cheaper than retrofitting. That's part of why we built &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-wcag-compliance-guide" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;: every generated screen ships with accessibility props filled in, because the templates treat them as required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 20-minute test
&lt;/h2&gt;

&lt;p&gt;Before any of this: turn on VoiceOver (iOS) or TalkBack (Android), close your eyes, and try your app's main user flow. Not a demo — the real thing. If you can't finish it, neither can your users.&lt;/p&gt;

&lt;p&gt;That one exercise will tell you more about your priorities than any audit report.&lt;/p&gt;




&lt;p&gt;What's the worst accessibility bug you've found in your own RN app? Drop it in the comments. I'm collecting them for a follow-up post.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>a11y</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
    <item>
      <title>6 Supabase RLS Policies That Pass Code Review and Still Leak Data</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Wed, 30 Sep 2026 07:17:16 +0000</pubDate>
      <link>https://dev.to/mason_roy/6-supabase-rls-policies-that-pass-code-review-and-still-leak-data-1hi5</link>
      <guid>https://dev.to/mason_roy/6-supabase-rls-policies-that-pass-code-review-and-still-leak-data-1hi5</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;enable row level security&lt;/code&gt; with zero policies is safe. Forgetting to enable it is a public table. Check every table, not just the ones you remember creating.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;using&lt;/code&gt; is for reading rows. &lt;code&gt;with check&lt;/code&gt; is for writing them. An &lt;code&gt;update&lt;/code&gt; policy with only &lt;code&gt;using&lt;/code&gt; lets users move rows to someone else.&lt;/li&gt;
&lt;li&gt;Never put authorization data in &lt;code&gt;raw_user_meta_data&lt;/code&gt;. The user can edit it. Use &lt;code&gt;app_metadata&lt;/code&gt; or a separate table.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;to public&lt;/code&gt; includes &lt;code&gt;anon&lt;/code&gt;. Say &lt;code&gt;to authenticated&lt;/code&gt; when you mean logged-in users.&lt;/li&gt;
&lt;li&gt;Views and &lt;code&gt;security definer&lt;/code&gt; functions bypass RLS silently. Audit them.&lt;/li&gt;
&lt;li&gt;Wrap &lt;code&gt;auth.uid()&lt;/code&gt; in &lt;code&gt;(select auth.uid())&lt;/code&gt; or your policies will be slow at scale.&lt;/li&gt;
&lt;li&gt;Every one of these is testable. If your RLS isn't tested, it's a hope.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Row Level Security is the reason Supabase works from a mobile client at all. Your anon key is in the app bundle, anyone can extract it, and the only thing between them and your &lt;code&gt;orders&lt;/code&gt; table is a policy you wrote in SQL at 11pm.&lt;/p&gt;

&lt;p&gt;I've reviewed a lot of RLS. The scary ones aren't the obviously wrong policies — those get caught. The scary ones are the ones that read correctly, pass review, and leak in a way that only shows up when someone tries. Here are the six I see most, with the fix and the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The table you forgot to enable RLS on
&lt;/h2&gt;

&lt;p&gt;Not a bad policy. No policy — because RLS was never turned on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- ...and nothing else&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This table is readable and writable by anyone with the anon key. Supabase's dashboard warns you, but the dashboard isn't in your CI. Migrations written by hand (or by an AI) skip this constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; is to make it structurally impossible to forget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Put this in your first migration and never remove it&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables_without_rls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;stable&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;every public table has RLS enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tables_without_rls&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt; &lt;span class="c1"&gt;// prints the offending table name when it fails&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on every migration. This one test has caught more real leaks for me than the other five combined.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;update&lt;/code&gt; with &lt;code&gt;using&lt;/code&gt; but no &lt;code&gt;with check&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the one that reads correctly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users update own profile"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;using&lt;/code&gt; decides which &lt;em&gt;existing&lt;/em&gt; rows the user can touch. &lt;code&gt;with check&lt;/code&gt; decides what the row is allowed to look like &lt;em&gt;after&lt;/em&gt; the write. With only &lt;code&gt;using&lt;/code&gt;, a user can select their own row — and then update its &lt;code&gt;id&lt;/code&gt; to someone else's, or on a table with a &lt;code&gt;user_id&lt;/code&gt; column, reassign ownership entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Alice, logged in, doing something she shouldn't be able to&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BOB_ID&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;   &lt;span class="c1"&gt;// now Bob owns Alice's spam post&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alicesPostId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// passes `using`, no `with check` to stop it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users update own posts"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cannot reassign a row to another user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signInAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;alice&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BOB_ID&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alicesPostId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;42501&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// insufficient_privilege&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same applies to &lt;code&gt;insert&lt;/code&gt; — &lt;code&gt;with check&lt;/code&gt; is the only clause that matters there, and a missing one means users can insert rows with any &lt;code&gt;user_id&lt;/code&gt; they like.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Authorization from &lt;code&gt;raw_user_meta_data&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Someone wants an admin flag. The fastest place to put it is user metadata, because it's right there in the JWT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"admins see everything"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user_metadata'&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user can update their own &lt;code&gt;user_metadata&lt;/code&gt; through the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// refresh session, now they're an admin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the whole exploit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; &lt;code&gt;app_metadata&lt;/code&gt; is only writable with the service role, so it's safe to trust. Better still, keep roles in a table and join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- deliberately no policies: nobody can read or write this from the client&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"admins see all orders"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt;
      &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user cannot self-promote via metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mallory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signInAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mallory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mallory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mallory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refreshSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mallory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// still sees nothing&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. &lt;code&gt;to public&lt;/code&gt; when you meant &lt;code&gt;to authenticated&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read published posts"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;published&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;to&lt;/code&gt; clause means &lt;code&gt;to public&lt;/code&gt;, which includes the &lt;code&gt;anon&lt;/code&gt; role. Sometimes that's intended — published posts probably &lt;em&gt;should&lt;/em&gt; be public. But copy that pattern to a table where it isn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own notifications"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;anon&lt;/code&gt; gets &lt;code&gt;auth.uid()&lt;/code&gt; as &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;null = user_id&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, and the policy fails closed. Fine — by accident. Change the policy to use &lt;code&gt;coalesce&lt;/code&gt; or a &lt;code&gt;not exists&lt;/code&gt;, or add a &lt;code&gt;or public = true&lt;/code&gt; branch, and suddenly the accident stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; is to be explicit every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own notifications"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anon cannot read notifications at all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ANON_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. The view that bypasses everything
&lt;/h2&gt;

&lt;p&gt;Views in Postgres run with the permissions of their &lt;em&gt;owner&lt;/em&gt; by default, not the caller. The owner is usually &lt;code&gt;postgres&lt;/code&gt;, which bypasses RLS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="n"&gt;order_summaries&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every authenticated user can now &lt;code&gt;select * from order_summaries&lt;/code&gt; and get every order and every email in the system. The &lt;code&gt;orders&lt;/code&gt; policies are fine. The view doesn't use them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; (Postgres 15+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="n"&gt;order_summaries&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;security_invoker&lt;/code&gt; makes the view run as the caller, so the underlying RLS applies. Same story for functions — &lt;code&gt;security definer&lt;/code&gt; functions bypass RLS, which is sometimes exactly what you want (the &lt;code&gt;tables_without_rls&lt;/code&gt; helper above needs it), and sometimes a hole. Audit every one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pronamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order_summaries respects caller RLS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signInAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order_summaries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;aliceOrderIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. &lt;code&gt;auth.uid()&lt;/code&gt; without the &lt;code&gt;select&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This one doesn't leak. It's here because it's the one that makes people &lt;em&gt;remove&lt;/em&gt; RLS when the table gets big.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres calls &lt;code&gt;auth.uid()&lt;/code&gt; once per row. On a 5-million-row table with a &lt;code&gt;user_id&lt;/code&gt; index, that's 5 million function calls before the index is even considered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapped in a subselect, Postgres evaluates it once and treats it as a constant, so the index is used. On a table I looked at last month this was the difference between 3.2 seconds and 4 milliseconds. Nobody had leaked anything; they'd just been about to turn RLS off "temporarily" for performance.&lt;/p&gt;

&lt;p&gt;Same rule for &lt;code&gt;auth.jwt()&lt;/code&gt; and anything else that's stable within a query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it in CI
&lt;/h2&gt;

&lt;p&gt;All six tests together are about 80 lines. They need a real Postgres with your migrations applied and a way to sign in as different test users — which is a one-line &lt;code&gt;supabase start&lt;/code&gt; in CI, or a lighter single-process alternative if you don't want Docker there.&lt;/p&gt;

&lt;p&gt;The pattern I use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// test/rls.setup.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signInAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ANON_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithPassword&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@test.local`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test-password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seed the users in &lt;code&gt;seed.sql&lt;/code&gt;, write one test per policy, run on every PR. The tests are boring. That's the point — the day one of them goes red is the day you didn't ship a leak.&lt;/p&gt;

&lt;p&gt;If you're starting from a template rather than a blank project, check whether it ships with these. The &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=supabase-rls-policies-that-leak" rel="noopener noreferrer"&gt;AppLighter&lt;/a&gt; Expo + Supabase foundation is the one I've seen that has the RLS test harness in it out of the box — the &lt;code&gt;tables_without_rls&lt;/code&gt; check and per-policy tests wired into CI — which matters more than any single policy, because it's the thing that catches the seventh mistake I haven't found yet.&lt;/p&gt;




&lt;p&gt;Which of these have you hit? And more usefully — which one am I missing? Drop it in the comments and I'll add it with a test.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>Expo OTA Updates Are a Loaded Gun. Here's the Safety.</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:32:17 +0000</pubDate>
      <link>https://dev.to/mason_roy/expo-ota-updates-are-a-loaded-gun-heres-the-safety-1c1k</link>
      <guid>https://dev.to/mason_roy/expo-ota-updates-are-a-loaded-gun-heres-the-safety-1c1k</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;runtimeVersion&lt;/code&gt; is the only thing standing between an OTA update and a crash loop. Use the &lt;code&gt;fingerprint&lt;/code&gt; policy, not &lt;code&gt;appVersion&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An OTA update can't change native code. If you touched &lt;code&gt;ios/&lt;/code&gt;, &lt;code&gt;android/&lt;/code&gt;, a config plugin, or a package with native code, ship a store build. No exceptions.&lt;/li&gt;
&lt;li&gt;Default &lt;code&gt;checkOnLaunch&lt;/code&gt; + &lt;code&gt;fallbackToCacheTimeout: 0&lt;/code&gt; means users get the update &lt;em&gt;next&lt;/em&gt; launch, not this one. Decide if that's what you want.&lt;/li&gt;
&lt;li&gt;Test updates on a preview build with a dedicated channel. &lt;code&gt;expo-updates&lt;/code&gt; is disabled in dev clients.&lt;/li&gt;
&lt;li&gt;Rollback isn't a button. It's republishing a known-good update. Know the command before you need it.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;OTA updates are the best thing about Expo and the fastest way I've shipped a broken app to 100% of users in under a minute.&lt;/p&gt;

&lt;p&gt;That's not a contradiction. &lt;code&gt;eas update&lt;/code&gt; is a loaded gun. It's incredibly useful. It also has no safety on by default. Here's the one I've built up over a couple of years of using it in production, one incident at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The runtime version mismatch (aka the crash loop)
&lt;/h2&gt;

&lt;p&gt;This is the big one. Every OTA update is tagged with a &lt;code&gt;runtimeVersion&lt;/code&gt;. Every native build has one too. Expo only delivers an update to a build with a &lt;em&gt;matching&lt;/em&gt; runtime version. If you get this wrong in the permissive direction — an update that matches a build it shouldn't — you ship JS that calls a native module the build doesn't have, and the app crashes on launch. Forever. There's no "next update fixes it," because the app never runs long enough to fetch it.&lt;/p&gt;

&lt;p&gt;The default in a lot of older projects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"runtimeVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"appVersion"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;appVersion&lt;/code&gt; ties the runtime to the version string in &lt;code&gt;app.json&lt;/code&gt;. Sounds fine. It means: as long as you don't bump &lt;code&gt;version&lt;/code&gt;, every build is considered compatible. Add a native dependency, forget to bump, publish an update — crash loop for anyone on the old build.&lt;/p&gt;

&lt;p&gt;The fix is to let Expo compute it from what's &lt;em&gt;actually&lt;/em&gt; native in the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"runtimeVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fingerprint"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fingerprint&lt;/code&gt; hashes the native project — dependencies with native code, config plugins, &lt;code&gt;ios/&lt;/code&gt; and &lt;code&gt;android/&lt;/code&gt; if you have them. Change any of it and the fingerprint changes, so the next &lt;code&gt;eas update&lt;/code&gt; simply won't be delivered to old builds. You can check what you're about to ship against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo-updates fingerprint:generate
&lt;span class="c"&gt;# or compare against a specific build&lt;/span&gt;
eas fingerprint:compare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the fingerprint changed and you didn't expect it to, that's your signal that a store build is required, not an update.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Shipping native changes over the air
&lt;/h2&gt;

&lt;p&gt;Related, but a different mistake: knowing the rule and getting it wrong anyway because the change &lt;em&gt;didn't look native&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Things that are native and require a new build, that people routinely try to OTA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ Adding any package with an ios/ or android/ directory
✗ Changing a config plugin's options (permissions text, deep link schemes, etc.)
✗ Bumping Expo SDK version
✗ Changing app icon, splash screen, or bundle identifier
✗ Enabling/disabling the new architecture
✗ Updating expo-updates itself
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Things that are JS and are fine to OTA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Any .ts / .tsx / .js change
✓ Assets imported from JS (images via require(), fonts loaded via expo-font)
✓ Changes to app.json fields that are read at runtime (extra, updates config)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fingerprint&lt;/code&gt; policy catches all of the first list. But you should know the list anyway, because you'll want to plan a store release before you're blocked by one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Publishing to the wrong channel
&lt;/h2&gt;

&lt;p&gt;Channels are how you point a &lt;em&gt;build&lt;/em&gt; at a stream of updates. Branches are where updates live. A build says "I listen to channel &lt;code&gt;production&lt;/code&gt;." A channel is mapped to a branch. &lt;code&gt;eas update&lt;/code&gt; publishes to a branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;eas.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mistake is treating the branch name as a free-form string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# This creates a branch called "fix-login" that no build listens to.&lt;/span&gt;
&lt;span class="c"&gt;# Nothing happens. You think the update is out. It isn't.&lt;/span&gt;
eas update &lt;span class="nt"&gt;--branch&lt;/span&gt; fix-login &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s2"&gt;"fix login"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I do now, without exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Preview first — this hits the internal build the team has installed&lt;/span&gt;
eas update &lt;span class="nt"&gt;--channel&lt;/span&gt; preview &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s2"&gt;"fix: login race on cold start"&lt;/span&gt;

&lt;span class="c"&gt;# Once someone on the team confirms it on-device:&lt;/span&gt;
eas update &lt;span class="nt"&gt;--channel&lt;/span&gt; production &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s2"&gt;"fix: login race on cold start"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;--channel&lt;/code&gt; instead of &lt;code&gt;--branch&lt;/code&gt; means the update goes wherever that channel is currently pointed, which is what you actually mean. Check the mapping if in doubt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas channel:view production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Assuming the user has the update
&lt;/h2&gt;

&lt;p&gt;The default update behaviour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"updates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"checkAutomatically"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ON_LOAD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fallbackToCacheTimeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fallbackToCacheTimeout: 0&lt;/code&gt; means: on launch, immediately load the cached bundle, check for an update in the background, download it if there is one, and apply it &lt;em&gt;next time the app launches&lt;/em&gt;. Users are always one launch behind.&lt;/p&gt;

&lt;p&gt;That's the right default for a consumer app. It's the wrong default when you're fixing something urgent and the user opened the app to hit the bug. Two ways to close the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — prompt and reload:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Alert&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useUpdatePrompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__DEV__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// expo-updates is a no-op in dev&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAvailable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkForUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Update ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Restart to apply the latest version?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Later&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cancel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Restart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reloadAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;})().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="c1"&gt;// never let an update check crash the app&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B — apply on foreground:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useApplyUpdateOnForeground&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__DEV__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAvailable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkForUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reloadAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// hard reload — only do this if the user isn't mid-form&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option B is aggressive. Reloading while someone's halfway through a checkout is worse than the bug you're fixing. I use A by default and B only behind a "critical" flag in the update's metadata.&lt;/p&gt;

&lt;p&gt;Also: &lt;code&gt;__DEV__&lt;/code&gt; early-return matters. &lt;code&gt;expo-updates&lt;/code&gt; is disabled in development builds, and &lt;code&gt;checkForUpdateAsync&lt;/code&gt; throws. Wrap it or skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Not knowing how to roll back
&lt;/h2&gt;

&lt;p&gt;There's no undo. Rollback means publishing a &lt;em&gt;new&lt;/em&gt; update whose contents are the old update.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find the last known-good update&lt;/span&gt;
eas update:list &lt;span class="nt"&gt;--branch&lt;/span&gt; production

&lt;span class="c"&gt;# Republish it. This creates a new update with the old bundle.&lt;/span&gt;
eas update:republish &lt;span class="nt"&gt;--branch&lt;/span&gt; production &lt;span class="nt"&gt;--group&lt;/span&gt; &amp;lt;update-group-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it, but the key detail is that you should run &lt;code&gt;update:list&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the incident, so you know what a healthy list looks like and can spot the bad group in seconds instead of minutes. I keep the last three good group IDs in a pinned message in the team chat. Low-tech, works.&lt;/p&gt;

&lt;p&gt;If the bad update caused a crash loop (mistake #1), rollback won't save the users already affected — their app can't fetch anything. That's why #1 is first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config I start every project with
&lt;/h2&gt;

&lt;p&gt;Pulling it together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"runtimeVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fingerprint"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"updates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"checkAutomatically"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ON_LOAD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fallbackToCacheTimeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://u.expo.dev/&amp;lt;project-id&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;eas.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"development"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"developmentClient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus the &lt;code&gt;useUpdatePrompt&lt;/code&gt; hook at the root, and a &lt;code&gt;scripts/ota-release.sh&lt;/code&gt; that runs the fingerprint compare, refuses to publish if it changed, publishes to preview, and prints the production command for a human to run. That last step being manual is on purpose.&lt;/p&gt;

&lt;p&gt;If you'd rather not assemble that from scratch, the &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=expo-ota-updates-safety" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; boilerplate ships with the fingerprint policy, channel config, and update hook already wired — it's the setup above, minus the incidents that taught me each line.&lt;/p&gt;




&lt;p&gt;What's your OTA horror story? Drop it in the comments — I'm collecting them, and I'm sure there's a #6 I haven't been burned by yet.&lt;/p&gt;

</description>
      <category>expo</category>
      <category>reactnative</category>
      <category>mobile</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Build a Fitness Wearable App with React Native (HealthKit, Health Connect, and BLE)</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Mon, 28 Sep 2026 06:14:14 +0000</pubDate>
      <link>https://dev.to/mason_roy/how-to-build-a-fitness-wearable-app-with-react-native-healthkit-health-connect-and-ble-10m0</link>
      <guid>https://dev.to/mason_roy/how-to-build-a-fitness-wearable-app-with-react-native-healthkit-health-connect-and-ble-10m0</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A wearable companion app is a phone app that pairs with a device, streams data from it, persists it, and writes it back to Apple Health / Health Connect. It is not a "log your workouts" app.&lt;/li&gt;
&lt;li&gt;Stack: Expo SDK 52+, expo-router, &lt;code&gt;react-native-health&lt;/code&gt;, &lt;code&gt;react-native-health-connect&lt;/code&gt;, &lt;code&gt;react-native-ble-plx&lt;/code&gt;, Zustand, &lt;code&gt;expo-sqlite&lt;/code&gt;. Don't use bare RN, don't use &lt;code&gt;react-native-google-fit&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Design the six screens first, then wire HealthKit/Health Connect, then BLE, then background persistence.&lt;/li&gt;
&lt;li&gt;Standard GATT heart rate is ~40 lines. Reconnect logic and battery are where you'll actually spend your time.&lt;/li&gt;
&lt;li&gt;Store on-device first, sync to the cloud opportunistically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most "build a fitness app in React Native" tutorials stop at the easy part: a nice UI, a workout list, a chart. The interesting engineering starts once your phone has to talk to a device that isn't the phone — a Whoop, a Garmin, a Polar strap, an Apple Watch. This is the end-to-end version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a fitness wearable companion app actually is
&lt;/h2&gt;

&lt;p&gt;It's a &lt;strong&gt;phone app&lt;/strong&gt; whose job is to pair with a physical wearable (watch, chest strap, ring, bike sensor), read health data from it in real time, persist that data, and present it back to the user. Roughly 60–70% of the code is React Native on the phone; the rest is native permissions, Bluetooth adapters, and background delivery bridges to Apple HealthKit and Android Health Connect.&lt;/p&gt;

&lt;p&gt;If you've wired a Polar H10 into a running app, or streamed cycling power from an Assioma pedal into a dashboard, that's the shape of app this is about. It's structurally different from a workout logger, and confusing the two is the #1 reason people underestimate the timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tech stack (and why each piece is non-negotiable)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Expo (SDK 52+), config-plugin workflow&lt;/td&gt;
&lt;td&gt;Managed native config, EAS Build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Router&lt;/td&gt;
&lt;td&gt;expo-router v4&lt;/td&gt;
&lt;td&gt;File-based routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health (iOS)&lt;/td&gt;
&lt;td&gt;react-native-health&lt;/td&gt;
&lt;td&gt;HealthKit read/write, background delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health (Android)&lt;/td&gt;
&lt;td&gt;react-native-health-connect&lt;/td&gt;
&lt;td&gt;Health Connect (replaced Google Fit in 2025)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BLE&lt;/td&gt;
&lt;td&gt;react-native-ble-plx&lt;/td&gt;
&lt;td&gt;GATT client for any BLE peripheral&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State&lt;/td&gt;
&lt;td&gt;Zustand or Redux Toolkit&lt;/td&gt;
&lt;td&gt;One store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Charts&lt;/td&gt;
&lt;td&gt;victory-native&lt;/td&gt;
&lt;td&gt;Skia-backed, doesn't drop frames&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend (optional)&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;Postgres + Auth + realtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;expo-auth-session + Sign in with Apple&lt;/td&gt;
&lt;td&gt;App Store requirement if you offer any other login&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things worth stressing. &lt;strong&gt;Use Expo, not bare React Native&lt;/strong&gt; — every library above ships an Expo config plugin now, and the friction that used to justify bare has mostly gone. And &lt;strong&gt;Health Connect replaced Google Fit's developer API in 2025.&lt;/strong&gt; If a tutorial tells you to install &lt;code&gt;react-native-google-fit&lt;/code&gt;, close the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Design the screens before you write code
&lt;/h2&gt;

&lt;p&gt;The mistake is starting with BLE. BLE is the interesting part, so it will eat every hour you give it, and you'll end up with heart-rate data and no app to put it in.&lt;/p&gt;

&lt;p&gt;A minimum-viable companion app has six screens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding&lt;/strong&gt; — permissions (Bluetooth, Location, HealthKit / Health Connect, Notifications)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device pairing&lt;/strong&gt; — scan, pair, remember&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live session&lt;/strong&gt; — real-time HR, elapsed time, distance, power&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session summary&lt;/strong&gt; — charts, splits, HR zones, calories&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;History&lt;/strong&gt; — past sessions with quick stats&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile &amp;amp; settings&lt;/strong&gt; — units, connected devices, data export&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Scaffold those first. They're boring, and that's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Wire up HealthKit and Health Connect
&lt;/h2&gt;

&lt;p&gt;Users expect workouts to show up in Apple Health and Health Connect. Not writing there is a bug report you'll get in week one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;react-native-health react-native-health-connect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the plugins in &lt;code&gt;app.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"react-native-health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"isClinicalDataEnabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"healthSharePermission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow $(PRODUCT_NAME) to read your workout and heart rate data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"healthUpdatePermission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow $(PRODUCT_NAME) to save your workouts"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"react-native-health-connect"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read today's step count on both platforms with one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AppleHealthKit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;HealthValue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readRecords&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-health-connect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Platform&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTodaySteps&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;AppleHealthKit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStepCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HealthValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readRecords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Steps&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeRangeFilter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;between&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;endTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this hides: &lt;strong&gt;background delivery&lt;/strong&gt; on iOS lets HealthKit wake your app when a new sample lands — set it up once at boot and treat it like a push. Android gives you nothing equivalent from Health Connect; you poll on app resume, which is fine for workouts and wrong for continuous monitoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Talk to a wearable over BLE
&lt;/h2&gt;

&lt;p&gt;This is the real work. Most consumer wearables expose data over BLE using either a standard GATT service (heart rate &lt;code&gt;0x180D&lt;/code&gt;, cycling power &lt;code&gt;0x1818&lt;/code&gt;, running speed and cadence &lt;code&gt;0x1814&lt;/code&gt;) or a vendor protocol you'll reverse-engineer from their SDK docs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;react-native-ble-plx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plugin config (iOS 13+ needs both usage strings; Android 12+ needs &lt;code&gt;BLUETOOTH_SCAN&lt;/code&gt; and &lt;code&gt;BLUETOOTH_CONNECT&lt;/code&gt; runtime permissions):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"react-native-ble-plx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"isBackgroundEnabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"modes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"peripheral"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"central"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bluetoothAlwaysPermission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow $(PRODUCT_NAME) to connect to your heart rate monitor"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Subscribe to a standard heart-rate monitor — works with any GATT-compliant strap on the market:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BleManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Characteristic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-ble-plx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;buffer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;manager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BleManager&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HR_SERVICE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0000180d-0000-1000-8000-00805f9b34fb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HR_MEASUREMENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;00002a37-0000-1000-8000-00805f9b34fb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;subscribeToHeartRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onBpm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bpm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monitorCharacteristicForDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;HR_SERVICE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;HR_MEASUREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;characteristic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Characteristic&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;characteristic&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;characteristic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;is16Bit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x01&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bpm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;is16Bit&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt16LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="nf"&gt;onBpm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bpm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lessons from shipping BLE apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never scan continuously.&lt;/strong&gt; Scanning wakes the radio and kills battery on iOS. Scan for pairing only; once you have the device ID, &lt;code&gt;connectToDevice(id)&lt;/code&gt; directly next time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnect is a state machine, not a callback.&lt;/strong&gt; A worn strap drops every time the user walks out of range or sweats too much. You need an explicit &lt;code&gt;disconnected → reconnecting → connected&lt;/code&gt; state that survives foreground/background transitions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip either and your five-star reviews become "app drains my battery" one-stars within a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Persist, sync, and survive backgrounding
&lt;/h2&gt;

&lt;p&gt;You have a hook streaming BPM. Where does it go? The pattern that works in production is a &lt;strong&gt;three-tier store&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In-memory ring buffer&lt;/strong&gt; — last 60 seconds of raw samples for the live chart. Never persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-device SQLite&lt;/strong&gt; (&lt;code&gt;expo-sqlite&lt;/code&gt; or WatermelonDB) — the durable copy of every session. Batch writes every second, not per sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud (optional)&lt;/strong&gt; — Supabase or your own Postgres, synced when the network is there.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The on-device layer is non-negotiable. Users run out of cell coverage, and losing a workout to "cloud sync failed" is unforgivable. Store first, sync opportunistically.&lt;/p&gt;

&lt;p&gt;For long workouts, enable background modes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"infoPlist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"UIBackgroundModes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bluetooth-central"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fetch"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"android"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"FOREGROUND_SERVICE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FOREGROUND_SERVICE_HEALTH"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android 14+, a health-tracking foreground service is required to keep BLE alive with the screen off. Getting this wrong is a Play Store rejection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Ship it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas build &lt;span class="nt"&gt;--profile&lt;/span&gt; production &lt;span class="nt"&gt;--platform&lt;/span&gt; all
eas submit &lt;span class="nt"&gt;--platform&lt;/span&gt; ios
eas submit &lt;span class="nt"&gt;--platform&lt;/span&gt; android
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two App Store gotchas for health apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HealthKit apps need a privacy policy URL&lt;/strong&gt; in App Store Connect and usage descriptions in &lt;code&gt;Info.plist&lt;/code&gt; for every read/write category.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign in with Apple is mandatory&lt;/strong&gt; if you offer &lt;em&gt;any&lt;/em&gt; other login. Adding the button takes 30 seconds; a rejection and resubmit takes 3–7 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shortcut: generate the scaffold, write the wearable code
&lt;/h2&gt;

&lt;p&gt;After building a few of these, here's the honest split. The parts that make the app &lt;em&gt;yours&lt;/em&gt; — the device protocol, the training-load math, the coaching UX — are maybe 25% of the code. The other 75% is scaffold everyone builds identically: onboarding, permission flows, tab bar, session list, chart cards, settings, auth, a Postgres schema for &lt;code&gt;sessions&lt;/code&gt; and &lt;code&gt;samples&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That 75% is what an AI app builder like &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=how-to-build-fitness-wearable-companion-app-react-native" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates from a prompt: describe the six screens and the Supabase data layer once, get an Expo project with expo-router routes, a wired-up store, and RLS policies, then drop the BLE and HealthKit code from this post into it. You still write the interesting code. You just don't rewrite the tab bar again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Building a fitness wearable app in React Native isn't hard because tools are missing — it's hard because there are 30 small integrations (permissions, background modes, BLE reconnect, health-store round-trips) and every one has an edge case. Nail the stack, design screens before touching a peripheral, and treat disconnection as a first-class state, and you'll ship in weeks rather than quarters.&lt;/p&gt;

&lt;p&gt;What wearable are you building against? Drop a comment — especially if you've fought a vendor BLE protocol that doesn't follow the GATT spec. I'd like to hear which ones are the worst.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>healthkit</category>
      <category>mobile</category>
    </item>
    <item>
      <title>I Read Every React Native Support Ticket We Got Last Year. Here’s What I Learned.</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Fri, 25 Sep 2026 10:39:45 +0000</pubDate>
      <link>https://dev.to/mason_roy/i-read-every-react-native-support-ticket-we-got-last-year-heres-what-i-learned-4pd9</link>
      <guid>https://dev.to/mason_roy/i-read-every-react-native-support-ticket-we-got-last-year-heres-what-i-learned-4pd9</guid>
      <description>&lt;p&gt;There’s a moment, if you work with developer tooling, when you realize your support inbox is one of the most honest sources of developer feedback.&lt;/p&gt;

&lt;p&gt;People don't usually file a support ticket because everything is working perfectly. They file one because a build has been failing for hours, a production release is blocked, or something that worked yesterday suddenly doesn't work after an upgrade.&lt;/p&gt;

&lt;p&gt;We looked at a year of React Native support tickets and found recurring problems around builds, dependencies, Expo configuration, Supabase, and production setup.&lt;/p&gt;

&lt;p&gt;The interesting part isn't just what breaks. It's where developers lose the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build and dependency problems
&lt;/h2&gt;

&lt;p&gt;React Native projects sit on top of several layers: JavaScript, React Native, Expo, native iOS and Android projects, and third-party packages.&lt;/p&gt;

&lt;p&gt;When those layers get out of sync, errors can become difficult to diagnose.&lt;/p&gt;

&lt;p&gt;A package might expect a different React Native version. An Expo SDK upgrade might expose an incompatible native dependency. A clean build can suddenly fail even though the application code hasn't changed.&lt;/p&gt;

&lt;p&gt;This is why checking dependency compatibility before adding more packages matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expo build errors
&lt;/h2&gt;

&lt;p&gt;Expo simplifies a lot of React Native development, but build configuration can still become a source of problems.&lt;/p&gt;

&lt;p&gt;When a project depends on native modules, configuration has to remain compatible with the Expo SDK and the native projects being generated.&lt;/p&gt;

&lt;p&gt;A useful sanity check is to test the project from a clean clone rather than relying only on an existing local environment.&lt;/p&gt;

&lt;p&gt;If a fresh environment cannot reproduce the expected build, there may be configuration or dependency problems hiding in the developer's machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supabase and authentication issues
&lt;/h2&gt;

&lt;p&gt;Backend configuration creates another category of problems.&lt;/p&gt;

&lt;p&gt;A common example is authentication or database queries returning unexpected results even though the application code appears correct.&lt;/p&gt;

&lt;p&gt;With Supabase, Row Level Security policies can affect whether queries return data. Developers may see an empty result and initially assume the query itself is broken when the actual issue is authorization.&lt;/p&gt;

&lt;p&gt;Debugging these issues requires checking both the application code and the backend policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  React Native template problems
&lt;/h2&gt;

&lt;p&gt;A starter project can save significant setup time, but only if it remains maintainable after the initial setup.&lt;/p&gt;

&lt;p&gt;A template with outdated dependencies or unnecessary configuration can create more work later.&lt;/p&gt;

&lt;p&gt;Before adopting a React Native starter, it's worth checking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React Native and Expo versions&lt;/li&gt;
&lt;li&gt;Native dependency compatibility&lt;/li&gt;
&lt;li&gt;Build configuration&lt;/li&gt;
&lt;li&gt;Authentication setup&lt;/li&gt;
&lt;li&gt;Environment variable handling&lt;/li&gt;
&lt;li&gt;Whether the project works from a fresh clone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The number of features in a template isn't necessarily the most important factor.&lt;/p&gt;

&lt;p&gt;The real question is whether the project gives you a reliable starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production problems are different
&lt;/h2&gt;

&lt;p&gt;Something working in development doesn't guarantee that it will work in production.&lt;/p&gt;

&lt;p&gt;Production builds introduce additional variables: release configuration, native build settings, environment variables, API configuration, and platform-specific behavior.&lt;/p&gt;

&lt;p&gt;That's why reproducing production-like conditions as early as possible can reduce surprises later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What support tickets reveal
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from support requests is that many React Native problems aren't caused by one complicated piece of code.&lt;/p&gt;

&lt;p&gt;They're often caused by interactions between tools and dependencies.&lt;/p&gt;

&lt;p&gt;A project can have perfectly reasonable application code and still fail because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dependency expects another SDK version&lt;/li&gt;
&lt;li&gt;Native configuration is outdated&lt;/li&gt;
&lt;li&gt;A backend policy blocks a request&lt;/li&gt;
&lt;li&gt;Environment variables aren't available in the build&lt;/li&gt;
&lt;li&gt;A starter template contains legacy configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Understanding those layers makes debugging much easier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway
&lt;/h2&gt;

&lt;p&gt;React Native development isn't just about writing components.&lt;/p&gt;

&lt;p&gt;A large part of the developer experience comes from the tooling around those components: dependency management, native builds, backend configuration, and production environments.&lt;/p&gt;

&lt;p&gt;If you're starting a new React Native project, spending a little time validating the foundation can save much more time once development gets serious.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Shipping React Native Updates Without App Store Releases: A Practical Guide to OTA Updates</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:53:16 +0000</pubDate>
      <link>https://dev.to/mason_roy/-shipping-react-native-updates-without-app-store-releases-a-practical-guide-to-ota-updates-1g9p</link>
      <guid>https://dev.to/mason_roy/-shipping-react-native-updates-without-app-store-releases-a-practical-guide-to-ota-updates-1g9p</guid>
      <description>&lt;p&gt;Building a React Native app is only half the job.&lt;/p&gt;

&lt;p&gt;Once an app is in production, one of the biggest challenges is shipping fixes and improvements quickly without asking users to download a new version from the App Store or Google Play for every small change.&lt;/p&gt;

&lt;p&gt;This is where &lt;strong&gt;Over-The-Air (OTA) updates&lt;/strong&gt; become useful.&lt;/p&gt;

&lt;p&gt;With OTA updates, JavaScript and other supported application assets can be delivered to users after the app has already been installed. This makes it possible to fix UI issues, update business logic, or release content changes without going through a complete store release process.&lt;/p&gt;

&lt;p&gt;This article explains how OTA updates work in Expo/React Native, when to use them, and the important limitations developers should understand before relying on them in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What Are OTA Updates?&lt;/li&gt;
&lt;li&gt;Why OTA Updates Matter&lt;/li&gt;
&lt;li&gt;How OTA Updates Work&lt;/li&gt;
&lt;li&gt;OTA Updates With Expo&lt;/li&gt;
&lt;li&gt;Basic Setup&lt;/li&gt;
&lt;li&gt;Publishing an Update&lt;/li&gt;
&lt;li&gt;Checking for Updates&lt;/li&gt;
&lt;li&gt;When Updates Are Applied&lt;/li&gt;
&lt;li&gt;What OTA Updates Can and Cannot Change&lt;/li&gt;
&lt;li&gt;A Practical Production Strategy&lt;/li&gt;
&lt;li&gt;Common Mistakes&lt;/li&gt;
&lt;li&gt;OTA Updates and RapidNative&lt;/li&gt;
&lt;li&gt;Conclusion&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Are OTA Updates?
&lt;/h2&gt;

&lt;p&gt;OTA stands for &lt;strong&gt;Over-The-Air&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In a React Native application, a significant part of the application is written in JavaScript or TypeScript. That code is bundled and delivered with the application.&lt;/p&gt;

&lt;p&gt;Normally, when you change that code, you create a new application build and submit it to the App Store or Google Play.&lt;/p&gt;

&lt;p&gt;With OTA updates, supported JavaScript and asset changes can be distributed separately from the native application binary.&lt;/p&gt;

&lt;p&gt;The general flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer
   |
   | Build &amp;amp; publish update
   v
Update Server
   |
   | Download
   v
User's React Native App
   |
   | Apply update
   v
New JavaScript Bundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can significantly reduce the time between fixing an issue and getting that fix to users.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why OTA Updates Matter
&lt;/h2&gt;

&lt;p&gt;Imagine you discover a small production bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User opens Profile
        ↓
Profile screen crashes
        ↓
Bug is in JavaScript code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without OTA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fix bug
   ↓
Build application
   ↓
Submit to App Store / Play Store
   ↓
Review / rollout
   ↓
User downloads new version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With OTA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fix bug
   ↓
Publish OTA update
   ↓
App downloads update
   ↓
User receives the fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second workflow can be much faster.&lt;/p&gt;

&lt;p&gt;OTA updates are particularly useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JavaScript bug fixes&lt;/li&gt;
&lt;li&gt;UI improvements&lt;/li&gt;
&lt;li&gt;Business logic changes&lt;/li&gt;
&lt;li&gt;Copy/content changes&lt;/li&gt;
&lt;li&gt;Configuration changes supported by the application&lt;/li&gt;
&lt;li&gt;Small feature improvements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, OTA updates are &lt;strong&gt;not a replacement for native application releases&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  How OTA Updates Work
&lt;/h1&gt;

&lt;p&gt;An OTA system generally involves four important components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────┐
│ Developer           │
│ Changes JS / Assets │
└─────────┬──────────┘
          │
          ▼
┌────────────────────┐
│ Update Service      │
│ Stores update       │
└─────────┬──────────┘
          │
          ▼
┌────────────────────┐
│ React Native App    │
│ Checks for update   │
└─────────┬──────────┘
          │
          ▼
┌────────────────────┐
│ Download + Apply    │
└────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the installed application starts, it can check whether an update compatible with the current native runtime is available.&lt;/p&gt;

&lt;p&gt;If one exists, the application can download it and apply it according to the update strategy you've configured.&lt;/p&gt;

&lt;p&gt;The important concept here is &lt;strong&gt;compatibility&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An OTA update still has to work with the native code already installed on the user's device.&lt;/p&gt;




&lt;h1&gt;
  
  
  OTA Updates With Expo
&lt;/h1&gt;

&lt;p&gt;For Expo-based React Native applications, &lt;strong&gt;EAS Update&lt;/strong&gt; provides a managed way to distribute updates.&lt;/p&gt;

&lt;p&gt;The basic workflow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-updates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure the project for EAS Update.&lt;/p&gt;

&lt;p&gt;You can initialize EAS configuration with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas update:configure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will typically have an &lt;code&gt;eas.json&lt;/code&gt; configuration describing your build profiles.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"development"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"developmentClient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact configuration depends on your project's build and release strategy.&lt;/p&gt;




&lt;h1&gt;
  
  
  Basic Setup
&lt;/h1&gt;

&lt;p&gt;After configuring EAS Update, you can create an update using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also specify a branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas update &lt;span class="nt"&gt;--branch&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And provide a message describing the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--branch&lt;/span&gt; production &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s2"&gt;"Fix profile screen rendering issue"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The update is then published to the configured update service.&lt;/p&gt;




&lt;h1&gt;
  
  
  Publishing an Update
&lt;/h1&gt;

&lt;p&gt;A typical production workflow might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Make your changes&lt;/span&gt;
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; fix/profile-rendering

&lt;span class="c"&gt;# Test locally&lt;/span&gt;
npm start

&lt;span class="c"&gt;# Commit your changes&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: profile rendering"&lt;/span&gt;

&lt;span class="c"&gt;# Publish OTA update&lt;/span&gt;
eas update &lt;span class="nt"&gt;--branch&lt;/span&gt; production &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s2"&gt;"Fix profile rendering"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important distinction is that &lt;code&gt;eas update&lt;/code&gt; publishes an update to the OTA service; it does &lt;strong&gt;not&lt;/strong&gt; create a new App Store or Google Play binary.&lt;/p&gt;




&lt;h1&gt;
  
  
  Checking for Updates
&lt;/h1&gt;

&lt;p&gt;You can also control update behavior inside your application using &lt;code&gt;expo-updates&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkForUpdates&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkForUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetchU&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>javascript</category>
      <category>mobile</category>
      <category>reactnative</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Run Supabase Edge Functions Locally Without a Separate Runtime</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:58:20 +0000</pubDate>
      <link>https://dev.to/mason_roy/how-to-run-supabase-edge-functions-locally-without-a-separate-runtime-4bhf</link>
      <guid>https://dev.to/mason_roy/how-to-run-supabase-edge-functions-locally-without-a-separate-runtime-4bhf</guid>
      <description>&lt;h1&gt;
  
  
  Running Supabase Edge Functions Locally with tinbase
&lt;/h1&gt;

&lt;p&gt;Supabase Edge Functions are useful when you need server-side logic without building a separate backend service. But during local development, setting up the complete environment around a project can add friction.&lt;/p&gt;

&lt;p&gt;What if you could keep your local backend, database, authentication, and Edge Functions in one lightweight development environment?&lt;/p&gt;

&lt;p&gt;This is where &lt;strong&gt;tinbase&lt;/strong&gt; can be useful. It is a Supabase-compatible backend that can run locally and provides support for Edge Functions alongside database, Auth, Storage, Realtime, and Row Level Security.&lt;/p&gt;

&lt;p&gt;In this tutorial, we will create a simple Edge Function and invoke it from a JavaScript application.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Are Building
&lt;/h2&gt;

&lt;p&gt;The example will have this flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
   ↓
supabase.functions.invoke()
   ↓
tinbase
   ↓
Edge Function
   ↓
JSON Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that the client can continue using the familiar Supabase SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Create a Function
&lt;/h2&gt;

&lt;p&gt;A typical Supabase project keeps Edge Functions inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;supabase/
└── functions/
    └── hello/
        └── index.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;index.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello from the Edge Function!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function simply returns a JSON response.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Start tinbase
&lt;/h2&gt;

&lt;p&gt;With tinbase running locally, the backend can load functions from the project's &lt;code&gt;supabase/functions&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;The goal is to keep the local development environment simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tinbase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You now have a local Supabase-compatible backend that can handle the function alongside your other backend services.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Invoke the Function
&lt;/h2&gt;

&lt;p&gt;Because tinbase is compatible with the Supabase client API, you can invoke the function using &lt;code&gt;supabase-js&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application does not need a custom HTTP wrapper just to communicate with the function.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Passing Data to a Function
&lt;/h2&gt;

&lt;p&gt;Edge Functions become more useful when they accept input.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Hello, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;!`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client can send data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;er&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>backend</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Mobile App CI/CD with EAS Build and GitHub Actions</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:11:30 +0000</pubDate>
      <link>https://dev.to/mason_roy/mobile-app-cicd-with-eas-build-and-github-actions-48p6</link>
      <guid>https://dev.to/mason_roy/mobile-app-cicd-with-eas-build-and-github-actions-48p6</guid>
      <description>&lt;p&gt;The first time you ship a React Native app, the code is the easy part. The hard part is everything after git push: signing certificates that expire, provisioning profiles that don't match, a Fastlane script from a two-year-old blog post, a keystore.jks that lives on one laptop, and a build that succeeds locally on macOS but fails on Ubuntu because Xcode isn't there.&lt;/p&gt;

&lt;p&gt;React Native CI/CD is the discipline of turning that mess into a repeatable pipeline: every push runs lint and tests, every merge to main reaches users quickly, and every release goes to TestFlight and Google Play with no one touching Xcode. In 2026, one of the cleanest ways to build that pipeline is a combination of two tools: Expo Application Services (EAS) for the native heavy lifting, and GitHub Actions for everything else.&lt;/p&gt;

&lt;p&gt;This guide walks through a pipeline you can actually copy: what belongs on each side, how to wire them together, how to handle secrets and code signing without leaking them, and how to add over-the-air (OTA) updates so most releases skip the app stores entirely.&lt;/p&gt;

&lt;p&gt;What "mobile app CI/CD" actually means&lt;/p&gt;

&lt;p&gt;Mobile app CI/CD is the automated pipeline that takes a React Native or Expo commit and turns it into a signed, distributable build without a human running Xcode or Android Studio. It combines continuous integration (lint, tests, type checks on every push) with continuous delivery (signed builds and store submissions on every release), and adds an OTA update layer so JavaScript-only changes reach users in minutes instead of days.&lt;br&gt;
That paragraph is the whole idea. The rest of the article is about how to implement it without spending your weekend debugging code-signing errors.&lt;/p&gt;

&lt;p&gt;There are three moving parts in every serious pipeline:&lt;/p&gt;

&lt;p&gt;CI checks: TypeScript, ESLint, unit tests, format checks. These are cheap, fast, and run fine on Linux.&lt;br&gt;
Native builds: compiling .ipa and .aab binaries. These need macOS machines (for iOS), signing keys, and a lot of setup.&lt;br&gt;
Distribution: uploading to TestFlight and Google Play internal testing, and pushing OTA updates.&lt;/p&gt;

&lt;p&gt;The mistake most teams make is trying to do all three in the same place. GitHub Actions can compile an iOS build; it will also take 15–20 minutes per attempt on a small macOS runner, drain your included minutes at a 10x multiplier, and force you to manage certificates by hand. EAS was built to do that specific job well. The pragmatic split is: GitHub Actions owns the code, EAS owns the binary.&lt;/p&gt;

&lt;p&gt;The split-brain problem, and why it's actually the right answer&lt;/p&gt;

&lt;p&gt;Expo supports both approaches: you can drive EAS from any CI service, or use Expo's own EAS Workflows. Running two systems felt wrong to me at first, until I looked at what each is actually good at.&lt;/p&gt;

&lt;p&gt;Concern GitHub Actions  EAS Build / Workflows&lt;br&gt;
Lint, TypeScript, unit tests    Native fit, generous free tier  Works, but uses paid CI minutes&lt;br&gt;
iOS .ipa compilation    Slow, hand-rolled signing   Purpose-built&lt;br&gt;
Android .aab compilation    Workable but manual One command&lt;br&gt;
Code signing (iOS certs, Android keystore)  Stored in Actions secrets, managed by hand  Managed credentials, stored encrypted and shared across the team&lt;br&gt;
OTA updates (EAS Update)    Triggered via CLI   First-class&lt;br&gt;
PR preview builds   Complex eas build --profile preview&lt;br&gt;
Skipping unnecessary native builds  DIY Fingerprint + get-build jobs in EAS Workflows&lt;br&gt;
Cost model  Cheap for Linux jobs, macOS burns minutes 10x faster    Plan fee plus usage-based build pricing&lt;/p&gt;

&lt;p&gt;The rule I've settled on: if a step needs Xcode, Ruby, or a keychain, it belongs in EAS. Everything else (the fast feedback loop developers actually feel) belongs in GitHub Actions. That way a broken test blocks a PR in a couple of minutes, and a full native build only runs when it actually needs to.&lt;/p&gt;

&lt;p&gt;Show Image Photo by Luca Bravo on Unsplash&lt;/p&gt;

&lt;p&gt;Step 1: Set up eas.json with real build profiles&lt;/p&gt;

&lt;p&gt;eas.json is the config file EAS reads to decide how to build your app. It lives at the root of your Expo project alongside app.json and package.json. Most tutorials show a single production profile. That's a trap. You need at least three, because dev, QA, and prod are different environments with different API URLs and distribution methods.&lt;/p&gt;

&lt;p&gt;Here's a working starting point:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "cli": {&lt;br&gt;
    "version": "&amp;gt;= 16.0.0",&lt;br&gt;
    "appVersionSource": "remote"&lt;br&gt;
  },&lt;br&gt;
  "build": {&lt;br&gt;
    "development": {&lt;br&gt;
      "developmentClient": true,&lt;br&gt;
      "distribution": "internal",&lt;br&gt;
      "environment": "development",&lt;br&gt;
      "ios": { "simulator": true }&lt;br&gt;
    },&lt;br&gt;
    "preview": {&lt;br&gt;
      "distribution": "internal",&lt;br&gt;
      "channel": "preview",&lt;br&gt;
      "environment": "preview"&lt;br&gt;
    },&lt;br&gt;
    "production": {&lt;br&gt;
      "channel": "production",&lt;br&gt;
      "environment": "production",&lt;br&gt;
      "autoIncrement": true&lt;br&gt;
    }&lt;br&gt;
  },&lt;br&gt;
  "submit": {&lt;br&gt;
    "production": {&lt;br&gt;
      "ios": {&lt;br&gt;
        "ascAppId": "1234567890"&lt;br&gt;
      },&lt;br&gt;
      "android": {&lt;br&gt;
        "track": "internal"&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;A few non-obvious decisions in there:&lt;/p&gt;

&lt;p&gt;appVersionSource: "remote" delegates the build number to EAS. This is the setting that finally kills the "who bumped the version last?" merge conflict. EAS tracks build numbers server-side, and autoIncrement bumps them on production builds.&lt;br&gt;
channel binds the build to an EAS Update channel. A preview-channel build only receives OTA updates published to the preview channel, so a QA release can't be accidentally overwritten by a main push.&lt;br&gt;
environment tells EAS which set of EAS environment variables (development, preview, or production) to load for the build. Put values like APP_ENV or EXPO_PUBLIC_API_URL there with eas env:create instead of hardcoding them in eas.json, so builds, updates, and local dev all read the same values.&lt;br&gt;
ios.simulator: true on development produces a build that runs in the iOS simulator on any Mac. Great for design review, useless on a physical device or for App Store submission. Add a second development profile without it if your team tests on real phones.&lt;br&gt;
The submit block has no credentials in it. No Apple ID, no path to a Google service account JSON. Those live in EAS (see Step 3), which is what lets submission run non-interactively from CI. A serviceAccountKeyPath pointing at a gitignored file is the most common reason --auto-submit works on a laptop and fails in CI.&lt;/p&gt;

&lt;p&gt;Full reference: Configuring EAS Build with eas.json.&lt;/p&gt;

&lt;p&gt;Step 2: The GitHub Actions workflow&lt;/p&gt;

&lt;p&gt;This is the file that ties everything together. It lives at .github/workflows/ci.yml. The design goal:&lt;/p&gt;

&lt;p&gt;Every PR runs lint + tests, then kicks off a preview EAS build.&lt;br&gt;
Every merge to main publishes an OTA update (added in Step 4).&lt;br&gt;
Every version tag (v1.4.0) runs a production EAS build and submits it to the stores.&lt;/p&gt;

&lt;p&gt;Store builds are deliberately tied to tags, not to every merge. A native build plus submission on every merge burns build credits, floods TestFlight with near-identical binaries, and is unnecessary when most merges are JavaScript-only.&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
name: CI/CD&lt;/p&gt;

&lt;p&gt;on:&lt;br&gt;
  pull_request:&lt;br&gt;
    branches: [main]&lt;br&gt;
  push:&lt;br&gt;
    branches: [main]&lt;br&gt;
    tags: ["v*"]&lt;/p&gt;

&lt;p&gt;permissions:&lt;br&gt;
  contents: read&lt;/p&gt;

&lt;p&gt;concurrency:&lt;br&gt;
  group: ${{ github.workflow }}-${{ github.ref }}&lt;br&gt;
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  quality:&lt;br&gt;
    name: Lint, type-check, test&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v5&lt;br&gt;
      - uses: actions/setup-node@v6&lt;br&gt;
        with:&lt;br&gt;
          node-version: 22&lt;br&gt;
          cache: npm&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - run: npm run lint&lt;br&gt;
      - run: npm run type-check&lt;br&gt;
      - run: npm test -- --ci --coverage&lt;/p&gt;

&lt;p&gt;preview-build:&lt;br&gt;
    name: EAS preview build&lt;br&gt;
    needs: quality&lt;br&gt;
    # Secrets are not available to PRs from forks, so skip those.&lt;br&gt;
    if: &amp;gt;-&lt;br&gt;
      github.event_name == 'pull_request' &amp;amp;&amp;amp;&lt;br&gt;
      github.event.pull_request.head.repo.full_name == github.repository&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v5&lt;br&gt;
      - uses: actions/setup-node@v6&lt;br&gt;
        with:&lt;br&gt;
          node-version: 22&lt;br&gt;
          cache: npm&lt;br&gt;
      - uses: expo/expo-github-action@v8&lt;br&gt;
        with:&lt;br&gt;
          eas-version: latest&lt;br&gt;
          token: ${{ secrets.EXPO_TOKEN }}&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - run: eas build --profile preview --platform all --non-interactive --no-wait&lt;/p&gt;

&lt;p&gt;production-release:&lt;br&gt;
    name: EAS production build + submit&lt;br&gt;
    needs: quality&lt;br&gt;
    if: github.event_name == 'push' &amp;amp;&amp;amp; startsWith(github.ref, 'refs/tags/v')&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v5&lt;br&gt;
      - uses: actions/setup-node@v6&lt;br&gt;
        with:&lt;br&gt;
          node-version: 22&lt;br&gt;
          cache: npm&lt;br&gt;
      - uses: expo/expo-github-action@v8&lt;br&gt;
        with:&lt;br&gt;
          eas-version: latest&lt;br&gt;
          token: ${{ secrets.EXPO_TOKEN }}&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - run: eas build --profile production --platform all --auto-submit --non-interactive --no-wait&lt;/p&gt;

&lt;p&gt;Read that carefully. The shape matters more than the details.&lt;/p&gt;

&lt;p&gt;quality runs on Ubuntu, not macOS. JS-only jobs have no business on a macOS runner. Standard Linux runners bill at about $0.006/minute versus about $0.062/minute for macOS, and macOS drains your included minutes 10x faster.&lt;br&gt;
--no-wait on both build jobs. The Actions job kicks off the EAS build and returns immediately. EAS runs the build on its own infrastructure, and Actions doesn't sit there burning minutes waiting on a native compile. With --auto-submit, the submission is queued on EAS and runs when the build finishes, so you don't need to wait for it either. The trade-off: the Actions job goes green when the build is queued, not when it succeeds. Watch build status in the EAS dashboard, or drop --no-wait if you want the job to fail when the build fails.&lt;br&gt;
--auto-submit on production. EAS builds the binary, then hands it directly to EAS Submit. It lands in TestFlight and the Google Play internal track on its own. Promoting it from there to public release is still a manual (and deliberate) step in App Store Connect and Play Console.&lt;br&gt;
EXPO_TOKEN authenticates the runner to your Expo account. For a team, create it from a robot user with the minimum role needed rather than using a personal access token. A personal token carries everything your account can do, and it disappears when you leave the team.&lt;br&gt;
npm run type-check assumes you have that script in package.json (typically tsc --noEmit).&lt;br&gt;
--platform all on every PR adds up. The EAS Free plan includes 15 Android and 15 iOS builds per month. See Step 5 for how to avoid building when native code hasn't changed.&lt;/p&gt;

&lt;p&gt;Expo's guide on triggering builds from CI goes deeper into edge cases like monorepos and other CI providers.&lt;/p&gt;

&lt;p&gt;Step 3: Handling secrets and code signing without losing your mind&lt;/p&gt;

&lt;p&gt;Code signing is where mobile CI/CD historically goes to die. iOS wants a distribution certificate, a provisioning profile, and a private key. Android wants a keystore, a key alias, and two passwords.&lt;/p&gt;

&lt;p&gt;How bad is losing them? It depends on the platform. iOS certificates and profiles can always be revoked and regenerated from your Apple Developer account. Android is the scary one: if you are not enrolled in Play App Signing and you lose your keystore, you cannot update your existing app. With Play App Signing (the default for new apps), Google holds the app signing key and you can request an upload key reset, which is painful but survivable.&lt;/p&gt;

&lt;p&gt;iOS. EAS handles this almost entirely with managed credentials. On your first interactive eas build, it offers to generate and store the certificate and provisioning profile for you. Say yes. They are stored encrypted on EAS servers and are the same for every developer and every CI run. If you have existing credentials (say, from a legacy Fastlane pipeline), you can upload them once with eas credentials.&lt;/p&gt;

&lt;p&gt;Two things to know so CI doesn't surprise you:&lt;/p&gt;

&lt;p&gt;Distribution certificates expire after a year. EAS does not silently replace them; regenerating requires authenticating with Apple. An expired certificate does not affect apps already on the store, only your ability to make new builds.&lt;br&gt;
Non-interactive runs can't prompt for an Apple ID and 2FA code. Run eas credentials --platform ios once on your machine, create an App Store Connect API key, and choose the option to use it for EAS Submit. That key is what lets --auto-submit work from CI.&lt;/p&gt;

&lt;p&gt;Android. EAS can generate a keystore or you can upload your existing one. For submission, EAS needs a Google Service Account key. Upload it to your project's credentials once, either in the EAS dashboard (Credentials → Android → your application identifier → Service Credentials) or with:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
eas credentials --platform android&lt;/p&gt;

&lt;h1&gt;
  
  
  Google Service Account → Upload a Google Service Account Key
&lt;/h1&gt;

&lt;p&gt;Don't commit the JSON, and don't reference it with serviceAccountKeyPath in eas.json if you want CI to work. Also note that Google requires the very first upload of a new app to be done manually in Play Console before API submissions work.&lt;/p&gt;

&lt;p&gt;Other secrets. For build-time values like SENTRY_AUTH_TOKEN or NPM_TOKEN, use EAS environment variables. The older eas secret:* commands are deprecated in favor of eas env:*:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
eas env:create --name SENTRY_AUTH_TOKEN --value "xxxx" \&lt;br&gt;
  --environment production --visibility secret&lt;/p&gt;

&lt;p&gt;Variables have three visibility levels: plain text, sensitive, and secret. Secret values are never readable outside EAS servers, not even in the dashboard or CLI.&lt;/p&gt;

&lt;p&gt;On the GitHub Actions side, the only secret you need is EXPO_TOKEN. All the app-signing material stays inside EAS, which means:&lt;/p&gt;

&lt;p&gt;Signing keys never touch the GitHub runner's disk or environment, so a leaked workflow log can't expose them.&lt;br&gt;
A new developer joining the team gets access via Expo organization membership, not by copying files around.&lt;br&gt;
There is one source of truth for credentials instead of a base64 blob per repo.&lt;/p&gt;

&lt;p&gt;One honest caveat: EXPO_TOKEN is still a powerful secret. Anyone who has it can trigger builds and, depending on the role behind it, manage credentials. That's another reason to use a scoped robot user, and to never expose it to workflows triggered by fork PRs.&lt;/p&gt;

&lt;p&gt;This is the single biggest reason to keep native builds off GitHub Actions. Managing an Apple distribution certificate inside ${{ secrets.IOS_P12 }} works, until it doesn't, and then you find out on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;Show Image Photo by FLY:D on Unsplash&lt;/p&gt;

&lt;p&gt;Step 4: Add OTA updates so most releases skip the stores&lt;/p&gt;

&lt;p&gt;The best CI/CD pipeline is the one you don't have to run. In a mature React Native app, the large majority of changes are JavaScript-only: a copy tweak, a style fix, a new screen using components that are already in the binary. These don't need a new native build. They just need to reach existing users' phones.&lt;/p&gt;

&lt;p&gt;That's what EAS Update is for. Add one job to the workflow:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
  ota-update:&lt;br&gt;
    name: EAS OTA update&lt;br&gt;
    needs: quality&lt;br&gt;
    if: github.event_name == 'push' &amp;amp;&amp;amp; github.ref == 'refs/heads/main'&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v5&lt;br&gt;
      - uses: actions/setup-node@v6&lt;br&gt;
        with:&lt;br&gt;
          node-version: 22&lt;br&gt;
          cache: npm&lt;br&gt;
      - uses: expo/expo-github-action@v8&lt;br&gt;
        with:&lt;br&gt;
          eas-version: latest&lt;br&gt;
          token: ${{ secrets.EXPO_TOKEN }}&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - name: Publish update&lt;br&gt;
        env:&lt;br&gt;
          COMMIT_MESSAGE: ${{ github.event.head_commit.message }}&lt;br&gt;
        run: &amp;gt;-&lt;br&gt;
          eas update --channel production --environment production&lt;br&gt;
          --message "$COMMIT_MESSAGE" --non-interactive&lt;/p&gt;

&lt;p&gt;Two details in that last step are easy to get wrong:&lt;/p&gt;

&lt;p&gt;Pass the commit message through env, never inline. Writing --message "${{ github.event.head_commit.message }}" directly in run: pastes untrusted text into a shell script. A commit message containing a quote or $(...) will break the step at best and execute arbitrary commands with access to your EXPO_TOKEN at worst.&lt;br&gt;
--environment is required on SDK 55 and later. It tells eas update which EAS environment variables to bundle with. On SDK 54 and earlier, omitting it falls back to local .env files.&lt;/p&gt;

&lt;p&gt;Now every merge to main publishes an OTA bundle within a few minutes. By default, the app downloads the update in the background on launch and applies it on the following launch, so expect users to see a change one cold start later unless you add your own reload logic with expo-updates.&lt;/p&gt;

&lt;p&gt;If pushing every merge straight to production users makes you nervous (it should, a little), point this job at a staging channel instead and promote to production with eas update:republish, or use the rollout percentage options to release gradually.&lt;/p&gt;

&lt;p&gt;Two caveats:&lt;/p&gt;

&lt;p&gt;Native changes are not OTA-updatable. If you add expo-camera this morning, it needs a new binary. The runtime version protects you here: an update is only delivered to binaries with a matching runtime version. Set "runtimeVersion": { "policy": "fingerprint" } in app.json and the runtime version changes automatically whenever anything affecting the native layer changes, so a JS bundle that expects a missing native module never reaches an old binary. When that happens, cut a new tag to ship a fresh binary.&lt;br&gt;
Fingerprints must be computed consistently. With the fingerprint policy, the hash calculated when you run eas update in GitHub Actions needs to match the one calculated during the EAS build. Lockfile drift or files that exist in one environment but not the other will produce a mismatch, and your update will target a runtime no binary has. If updates aren't arriving, compare fingerprints first (npx @expo/fingerprint fingerprint:generate).&lt;/p&gt;

&lt;p&gt;Also remember the store rules: OTA updates are for fixes and incremental improvements, not for changing what your app fundamentally does after review.&lt;/p&gt;

&lt;p&gt;Step 5: Cutting build time with fingerprints&lt;/p&gt;

&lt;p&gt;The same fingerprint that guards your OTA updates can also save you from running native builds you don't need. The fingerprint hashes the parts of your project that affect the native binary: dependencies, app.json, native folders, config plugins. If the hash matches an existing build, the native side hasn't changed, and a new compile is wasted money.&lt;/p&gt;

&lt;p&gt;This is not something plain eas build does for you automatically. It's a pattern you assemble in EAS Workflows from three pre-packaged job types:&lt;/p&gt;

&lt;p&gt;fingerprint computes the hash for each platform&lt;br&gt;
get-build looks for an existing build with that hash&lt;br&gt;
build runs only if nothing was found (and optionally repack injects the new JS bundle into the existing binary, so testers still get an installable artifact)&lt;br&gt;
yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  .eas/workflows/pr-preview.yml
&lt;/h1&gt;

&lt;p&gt;name: PR preview&lt;br&gt;
on:&lt;br&gt;
  pull_request:&lt;br&gt;
    branches: [main]&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  fingerprint:&lt;br&gt;
    type: fingerprint&lt;/p&gt;

&lt;p&gt;get_ios_build:&lt;br&gt;
    needs: [fingerprint]&lt;br&gt;
    type: get-build&lt;br&gt;
    params:&lt;br&gt;
      fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}&lt;br&gt;
      profile: preview&lt;/p&gt;

&lt;p&gt;build_ios:&lt;br&gt;
    needs: [get_ios_build]&lt;br&gt;
    if: ${{ !needs.get_ios_build.outputs.build_id }}&lt;br&gt;
    type: build&lt;br&gt;
    params:&lt;br&gt;
      platform: ios&lt;br&gt;
      profile: preview&lt;/p&gt;

&lt;p&gt;Expo reports cutting its own CI build times by up to 78% with the fingerprint + repack approach. For a JS-only PR, you go from a full native compile to roughly the time it takes to compute a hash and bundle JavaScript.&lt;/p&gt;

&lt;p&gt;If you adopt this, move the preview-build job out of GitHub Actions and into an EAS Workflow like the one above, and leave lint and tests where they are. If you'd rather stay entirely in GitHub Actions, check the expo-github-action README for its fingerprint-related sub-actions, which can compare fingerprints on a PR and decide between building and publishing an update.&lt;/p&gt;

&lt;p&gt;Where RapidNative fits into all of this&lt;/p&gt;

&lt;p&gt;The problem with every tutorial like this one, including this one, is that it assumes you already have a working Expo project. The wiring above is straightforward when you already have eas.json, an EXPO_TOKEN, and a project with sensible module boundaries. It's much less straightforward if you're starting from a boilerplate someone copy-pasted three years ago.&lt;/p&gt;

&lt;p&gt;RapidNative generates Expo apps from natural-language prompts, and the export ships with the pieces that make this pipeline possible on day one:&lt;/p&gt;

&lt;p&gt;eas.json with development, preview, and production profiles already scaffolded, matching the structure in Step 1.&lt;br&gt;
app.json with the runtime version set to the fingerprint policy, so OTA updates are automatically scoped to compatible binaries.&lt;br&gt;
A monorepo layout (mobile/ for the React Native app, web/ if you generated a web version). In that case, set working-directory: mobile on the workflow steps above.&lt;br&gt;
&amp;lt;!-- VERIFY BEFORE PUBLISHING: state the Expo SDK / React Native / TypeScript versions the export currently ships with. The original draft said RN 0.81 (Expo SDK 54); as of September 2026 the current release is SDK 57, and SDK 56 shipped with RN 0.85. --&amp;gt; A current Expo SDK and TypeScript setup, so you're not starting your pipeline with an SDK upgrade.&lt;/p&gt;

&lt;p&gt;The reason this matters: we kept watching teams get a great AI-generated app, then lose two days rebuilding the project's scaffolding into something CI could actually consume. The whole reason RapidNative uses Expo over bare React Native is that Expo is the shortest path from "code exists" to "binary is signed and on TestFlight."&lt;/p&gt;

&lt;p&gt;If you're not using RapidNative, none of this pipeline requires it. If you are, you can drop the workflow above into .github/workflows/ci.yml, add an EXPO_TOKEN, run one interactive eas build to set up credentials, and push. You can try RapidNative here.&lt;/p&gt;

&lt;p&gt;Show Image Photo by Redd Francisco on Unsplash&lt;/p&gt;

&lt;p&gt;FAQ&lt;br&gt;
How much does EAS Build cost compared to GitHub Actions?&lt;/p&gt;

&lt;p&gt;As of September 2026, EAS has a Free plan with 15 Android and 15 iOS builds per month, a Starter plan at $19/month that includes $45 of build credit, and a Production plan at $199/month that includes $225 of build credit and two build concurrencies. Beyond the included credit, builds are billed per build based on platform and machine size.&lt;/p&gt;

&lt;p&gt;GitHub Actions is free for public repos and includes 2,000 minutes/month for private repos on the Free plan (3,000 on Pro and Team). macOS runners consume those minutes at a 10x multiplier, and overage on standard runners is about $0.006/minute for Linux versus $0.062/minute for macOS.&lt;/p&gt;

&lt;p&gt;On raw compute alone, GitHub Actions is often the cheaper option: a 20-minute iOS build on a standard macOS runner is a little over a dollar. What you're paying EAS for is everything around the compile: managed credentials, submission, internal distribution links, OTA hosting, and not maintaining Fastlane and a CI keychain. Price out your own build volume, and count engineer hours honestly when you do.&lt;/p&gt;

&lt;p&gt;Can I use GitHub Actions without EAS for React Native?&lt;/p&gt;

&lt;p&gt;Yes. You'd need macOS runners with Xcode, a CI-managed keychain, Fastlane (or equivalent) for signing and upload, and a lot of YAML. It's a valid choice for teams with existing native mobile expertise who don't want a vendor dependency. There's also a middle path: eas build --local runs the EAS build process on your own runner. For most React Native teams, especially those already on Expo modules, the maintenance cost of a hand-rolled pipeline ends up exceeding the price of EAS fairly quickly.&lt;/p&gt;

&lt;p&gt;Do I need EAS Workflows if I already use GitHub Actions?&lt;/p&gt;

&lt;p&gt;EAS Workflows is Expo's own CI/CD product. If your repo is mostly mobile and you want one dashboard, plus pre-packaged jobs like fingerprint, get-build, repack, submit, and Maestro tests, use Workflows. If you have a broader repo (backend services, web app, mobile app) and GitHub Actions is already the source of truth, keep it and use EAS for build, submit, and update. A hybrid (tests in Actions, native jobs in Workflows) works fine too. Note that Workflows jobs consume EAS CI minutes on your plan.&lt;/p&gt;

&lt;p&gt;What breaks first when a React Native CI pipeline goes wrong?&lt;/p&gt;

&lt;p&gt;In rough order of frequency: expired or mismatched iOS provisioning profiles, Android keystore password confusion after a team member leaves, EXPO_TOKEN belonging to the wrong account or a user who left, submission failing in CI because Apple or Google credentials only existed on someone's laptop, and native module additions reaching users over OTA because the runtime version was pinned by hand instead of fingerprinted. Managed credentials in EAS take most of the pain out of the first two; the rest are process problems solved by a robot user, credentials stored in EAS, and the fingerprint policy.&lt;/p&gt;

&lt;p&gt;The point of all this&lt;/p&gt;

&lt;p&gt;A mobile CI/CD pipeline is a boring, un-fun piece of infrastructure that has an outsized effect on how fast you can ship. When it works, no one notices. When it doesn't, every release is a two-day fire drill and your team stops shipping between store submissions.&lt;/p&gt;

&lt;p&gt;The pipeline in this article is deliberately not the most sophisticated one possible. There's no matrix build across Node versions, no Slack-notified rollout gates, no per-branch environment promotion. Those are worth adding later, but only after the core loop (push → tested → signed → shipped) runs on its own.&lt;/p&gt;

&lt;p&gt;If you're building the pipeline from scratch, start with the eas.json in Step 1 and the workflow in Step 2. If you're building it into an existing project, budget a day for the first successful production build and submission (mostly iOS signing and store credentials) and another day for the OTA update wiring.&lt;/p&gt;

&lt;p&gt;Either way, the goal is the same: a git push that ends with your users getting the new version, and nobody touching Xcode along the way.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>mobile</category>
      <category>reactnative</category>
    </item>
  </channel>
</rss>
