How RapidNative's QR Code Preview Works on Real Devices
By Riya
3rd Sep 2026
Last updated: 3rd Sep 2026
You describe an app in plain English. Fifteen seconds later, you point your iPhone camera at a QR code on the screen and — before you've finished setting the phone down — the app is running. Real React Native, in Expo Go, talking to a real Postgres database, with hot reload wired up so the next prompt updates the screen you're already holding.
That "before you've finished setting the phone down" bit is the interesting part. Under the hood, that QR code is the front of a chain that includes a hostname reservation, a Firecracker microVM waking from a suspended memory snapshot, a Metro bundler warming up from a baked cache, and a file-sync worker propagating your AI-generated code from a Supabase row into the guest's filesystem — all in the couple of seconds it takes you to raise the phone.
This post is the honest architectural version of that story. We'll walk from the QR encoding itself down to the microVM boot, cover the two-hostname pair the QR relies on, and end with how AI writes reach the running app while you're still typing.
The 40-word version (for the impatient)
How does a RapidNative QR code preview work on a real device? The QR encodes an exps:// deep link pointing at a per-project subdomain. That subdomain resolves through Caddy to a Firecracker microVM running Metro. Expo Go opens the link, Metro bundles your app, and the bundle streams to your phone in under three seconds.
That's the shape. The rest of this post is the why at each step.
What the QR code actually contains
The first thing to understand is that the QR code does not contain your app. It doesn't contain a bundle, a JavaScript file, or any code. It contains a URL — specifically, an Expo Go deep link that looks like this in production:
exps://my-fitness-tracker-expo-dev.rapidnative.app
Two things are worth noticing.
First, the scheme is exps://, not exp://. This distinction cost us a support ticket or two before we got it right. Expo Go historically used the exp:// scheme on local networks, where cleartext HTTP to a LAN IP is fine. But in production our dev servers sit behind public HTTPS at rapidnative.app, and Android's cleartext-traffic policy blocks plain HTTP to public domains at the network layer — the request never even reaches Metro. Expo Go surfaces that as "Failed to download remote update," which reads like a server error but is really an OS-level block. So RapidNative-hosted previews use exps:// (which maps to HTTPS/443), while local development against a .test domain keeps exp:// for cleartext-to-LAN convenience. The scheme is picked by a one-line rule in src/shared/utils/expo-dev-url.ts.
Second, the host is a per-project subdomain, not a shared server with a project ID query param. That's a deliberate choice that pays off in three ways: TLS terminates cleanly per hostname, Metro's EXPO_PACKAGER_PROXY_URL becomes trivial to set, and — most importantly — the routing layer (Caddy in front of our orchestrator) can send each hostname to a completely isolated workload.
The two hostnames every project actually has
Here's a detail almost no cloud-preview product surfaces: a RapidNative project doesn't have one hostname. It has a pair.
For a project whose pretty slug is fitness-tracker, the QR pair is:
| Host | Purpose |
|---|---|
fitness-tracker.rapidnative.app | The web build — a browser-openable version of the app |
fitness-tracker-expo-dev.rapidnative.app | The Metro dev server — what Expo Go connects to |
Both point at the same primary mobile workload, but they exist as separate route entries in our orchestrator. Why?
Because a slug is a pair. If you attach only <slug>.<base> and someone scans the QR from an email a week later, Expo Go tries exps://<slug>-expo-dev.<base> and the router returns no route for request. To the user, this reads as "my phone can't reach the app" — a device problem — when in fact it's a missing route entry. So prettyDomainHosts() in src/modules/api/services/orchd/routes.ts treats a pretty slug as a two-host claim, and both attach paths funnel through the same helper.
There's a piece of history baked into this: projects created before the pair convention existed are healed on open by healPrettyDomainAlias, which noticed the missing -expo-dev alias and adds it once, gated on the routes already in hand so it costs no extra database query. We shipped that heal after one project's QR code silently pointed at a stale, deleted workload for a week (the mindful-coach-main incident, September 2026). The blog post's polished conclusion in reality started life as a bug report.
What the hostname resolves to: a Firecracker microVM
When Expo Go opens the deep link, DNS resolves fitness-tracker-expo-dev.rapidnative.app to our orchestration box. Caddy terminates TLS and forwards the request to our workload orchestrator (orchd — open source), which routes it by hostname to the project's mobile workload.
That workload isn't a container. It's a Firecracker microVM.
Firecracker is AWS's KVM-based microVM runtime — the same technology behind AWS Lambda and Fargate. Each RapidNative project's mobile dev server runs inside its own microVM, snapshot-suspended when nobody's connected, resumed from the snapshot when the first request lands.
The specific reason we switched from Docker+gVisor to Firecracker for the mobile workload only: wake latency. On the old Docker runtime, a suspended mobile workload took 60–90 seconds to serve its first bundle — enough that a user would scan the QR, see nothing happen, close the app, scan again, and see nothing again. Three scans per real preview. On Firecracker, that same wake completes in 0.1–3.4 seconds measured across the fleet. One scan, one preview.
That's not a config knob we flipped and forgot. It's an ongoing constraint: db, api, and web workloads stay as Docker containers (their cold-start doesn't sit in the user's critical path the same way), and orchd decides per-project by reading ORCHD_FC_WORKLOADS=fullstack-supabase/mobile from its own service config. If a microVM creation fails for a specific project, orchd falls back to a container for that workload's lifetime — which is why "my QR preview is slow" is a real signal worth debugging, not just a network complaint.
Per-project disk while running is near zero. A suspended mobile costs about 150MB of compressed memory snapshot. That's the economics that lets us keep a preview alive for every project a user has ever opened, rather than having to kill and re-provision on each visit.
The Metro handshake
Once the microVM is running (or was already awake), Expo Go's request lands on the Metro bundle server inside the guest. Metro is React Native's JavaScript bundler; its dev server does three things over the same HTTPS connection:
- Serves the initial bundle. Expo Go requests
index.bundleand Metro returns your app's compiled JavaScript. This is where the "no build step" magic lives: there's no Xcode project, no Android Studio build, no.ipaor.apk. Expo Go itself is the compiled native app; your JavaScript loads into it. - Serves assets. Images, fonts, and other static files are fetched over the same server as the app requests them.
- Keeps an HMR (Hot Module Replacement) connection open. When a file changes, Metro pushes just the updated modules over a WebSocket to the running app. That's how the preview updates without a full reload.
Our Firecracker guest's boot wrapper (a shell script — run in the template's orchd.json) is where several performance details live:
- It points Metro's cache directories at
/dataand chains the per-project cache to a baked seed on the read side. The baked seed is generated during image build by warming a throwaway route that imports the heaviest packages, then snapshotting the result. So the first bundle a new project ever serves isn't cold-compiled from scratch — it hits a hot cache on the very first request. - It caps Metro worker count. Left uncapped, Metro spawns one worker per CPU, which on a shared host means memory bloat with no throughput gain.
- It sets
EXPO_PACKAGER_PROXY_URLto the project's public hostname, so Metro tells the client to fetch assets fromfitness-tracker-expo-dev.rapidnative.appinstead of the guest's internal IP. Without this, asset URLs would leak private network paths that don't resolve from the phone. - It reinstalls dependencies when
package.json's hash differs from the bakednode_modules/.pkg-hash. This is how AI-added packages actually reach the running project. If your prompt asked for "a chart library" and the AI addedvictory-native, the next boot install picks it up.
The file sync loop: how AI writes reach a running phone
Here's where the story stops looking like generic cloud IDE and starts looking specific to how RapidNative works.
When the AI (or you, via the editor) writes a file, it goes into a Postgres table called project_files. That's the source of truth — the version the editor's own preview reads via a separate in-browser pipeline described in our architecture overview.
But the device preview doesn't read from Postgres. It reads from the microVM's filesystem — the guest's /app directory that Metro is watching. So every file write also fires syncFileToOrchd (src/modules/api/utils/orchd-sync.ts), which pushes the change into the workloads that own that path.
That "workloads that own that path" phrase matters. A fullstack-supabase project has four workloads: db (Tinbase — our lightweight Postgres), api, web, and mobile. A file at mobile/app/(app)/index.tsx belongs to the mobile workload; a file at supabase/migrations/0001.sql belongs to db. The sync layer's workloadsForPath function knows the mapping, so each write only touches the workloads that actually need it.
Depending on what changed, sync does more than just write the file:
- A source file change — the common case — just writes and lets Metro's watcher pick it up. HMR pushes the update to the phone over the already-open WebSocket. Median time from database commit to on-device update: about 400ms.
- A
package.jsonchange restarts the owning workload, so the boot wrapper reinstalls dependencies (debounced by 3 seconds so a burst of writes doesn't trigger multiple restarts). - A
supabase/migrations/*.sqlchange restarts thedbworkload, which reapplies migrations idempotently on boot. - A
supabase/seed.sqlchange resets the database (wipe → fresh initdb → migrations → seed), so deleted rows actually reach the deployed demo app. Reset supersedes restart if both happen in one burst, and it's a deliberate destructive action gated to demo tiers.
There's a subtle third detail: a write to a suspended mobile workload wakes it. orchd resumes the microVM from its memory snapshot, then applies the write inside the guest. syncFileToOrchd doesn't need a special case — the wake happens transparently before the file lands. This is the ambient cost that makes "AI generation while your phone is idle in the drawer" work at all.
The pre-generation loading screen trick
There's one place the two-preview architecture (editor's in-browser preview vs the device workload) creates a small user-experience problem worth explaining.
When you first open a brand-new project — before you've prompted for anything — the project's mobile/app/(app)/index.tsx doesn't exist yet. If we did nothing, the device preview would be blank and confusing. But we can't just add a scaffold home screen as a project file, because it would then leak into the editor and the AI would see it and try to modify it.
The trick: push a branded "Building your app…" loading screen straight to the mobile workload's filesystem at provisioning time, bypassing the project files table entirely. The workload has the file. The editor doesn't know it exists. The AI doesn't know it exists. When the AI eventually writes the real home route, its sync overwrites the loader at the same path. Job done.
We shipped this the wrong way first — as a scaffold project file — and it did exactly what you'd expect: leaked into the editor, and never showed on device because the editor's Lifo preview rendered it there instead. Moving it to workload-only was the fix. It's the cleanest illustration of why the two-preview model matters: a project file is visible in both previews, so a device-only artifact must not be a project file.
Why Expo Go, not a custom dev client
A reasonable question: why route through Expo Go rather than shipping our own React Native shell app users install once?
Three reasons.
Zero install friction for the device side. Expo Go is on the App Store and Play Store today. A user with no RapidNative account can install it in 30 seconds, and then every project we host — theirs and everyone else's — works with the same app. A custom shell means asking every new user to install our binary before they can preview anything, which is a real drop-off point in the funnel.
Compatible with the Expo SDK contract. Expo Go supports a specific set of native modules. If we shipped a custom dev client, we could add more native modules — but then every project would be locked to our binary, and users couldn't test their exported project against stock Expo Go. Since we do offer full source-code export (users own the code and can eject to their own build pipeline), staying compatible with Expo Go's module set means the preview matches what happens if they build the project themselves.
Trade-off we accept: projects can't use native modules Expo Go doesn't ship with. In practice this affects a small fraction of the app ideas people build with RapidNative — the fitness trackers, marketplaces, social apps, dashboards, and internal tools that dominate our usage all fit comfortably inside Expo Go's SDK.
Common failure modes and what actually fails
If you've read this far, the interesting question isn't "how does it work when it works" — it's "how does it fail, and what does each failure feel like from the phone?"
Firecracker fallback to container. If microVM create fails for a specific project, orchd degrades to a Docker container for that workload's lifetime. The preview still works, but wake time jumps from ~1s to ~60s. Symptom: first-scan preview takes a very long time, subsequent scans (once the container is warm) are fine. Fix: delete the orchd project row and let it re-provision — the next open will retry the microVM path.
Missing hostname alias. Older projects were provisioned before the pair convention. If the -expo-dev alias is missing, browser preview works but QR scans get "no route for request" from Caddy. Symptom: preview loads on the laptop, phone can't connect. Fix: healPrettyDomainAlias runs on project open and adds the alias.
Cleartext scheme mismatch. If the URL uses exp:// in production, Android's cleartext policy blocks the request. Symptom: iOS works, Android fails with "Failed to download remote update." Fix: always emit exps:// for public domains — enforced by buildExpoGoUrl in src/shared/utils/expo-dev-url.ts.
Stale bundle from a squatting workload. When a project is deleted, teardown is best-effort — orchd may retain routes for a defunct workload. If a freed slug is re-minted and the squatter isn't evicted, the new project's QR routes to the dead workload's stale bundle. Fix: attachRouteClaimingHost treats orchd's 409 conflict as a question, checks project_domains for slug ownership, and evicts a verified foreign owner.
Each of these was a real incident before it was a paragraph.
The end-to-end timeline
Putting it all together, here's what happens between you pointing the camera and the app appearing:
| T (ms) | Event |
|---|---|
| 0 | Camera app decodes QR, opens exps:// deep link |
| 50 | Expo Go handles the deep link, resolves DNS |
| 150 | HTTPS handshake with Caddy on our box |
| 200 | Caddy forwards to orchd, orchd routes to mobile workload |
| 250 | (if suspended) Firecracker resumes microVM from snapshot |
| 1,500 | Metro serves the initial bundle from warm cache |
| 2,300 | Bundle downloads to phone (variable — depends on connection) |
| 2,800 | JavaScript executes, app renders |
| 2,850 | HMR WebSocket opens; next AI write will hot-reload |
If the workload is already warm, subtract the ~1s resume step and you're under 2 seconds. If it's a cold Docker fallback, add ~60s. The typical case in production is 2–4 seconds, and the outliers are dominated by network variance to the phone, not the server.
What's exported when you export
One last thing worth saying, because it's the reason a lot of this architecture exists.
RapidNative isn't a walled garden. When you export a project, you get the actual React Native source code, the Expo project structure, the Supabase migrations, and everything else needed to run the app entirely without us. The QR preview architecture described in this post is scaffolding for the iteration loop — it lets you write, prompt, and test faster than a laptop-based expo start ever could — but it's not lock-in. Everything that runs on our Firecracker microVMs also runs on your machine or your own cloud.
That constraint shapes every architectural decision above. Compatibility with stock Expo Go, plain package.json dependency resolution, standard Supabase migrations, no proprietary runtime — because the day you export, the whole preview stack has to disappear cleanly, and the app has to run on your terms.
That's the honest version of the story. Try it yourself — describe an app in one sentence, scan the QR, and see how fast the phone catches up to the prompt.
Ready to build your app?
Turn your idea into a production-ready React Native app in minutes.
Free tools to get you started
Free AI PRD Generator
Generate a professional product requirements document in seconds. Describe your product idea and get a complete, structured PRD instantly.
Try it freeFree AI App Name Generator
Generate unique, brandable app name ideas with AI. Get creative name suggestions with taglines, brand colors, and monogram previews.
Try it freeFree AI App Icon Generator
Generate beautiful, professional app icons with AI. Describe your app and get multiple icon variations in different styles, ready for App Store and Google Play.
Try it freeFrequently asked questions
What is RapidNative?
RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.
Can I export the code?
Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.
Is RapidNative free to use?
Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.
Do I need to know how to code?
No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.
How long does it take to build an app?
Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.