How We Built Instant React Native Preview Without a Build Step
(155 chars): We rebuilt Metro to run in the browser so React Native previews update in ~150ms with no server, no cold start, no build step. Here's how.
By Suraj Ahmed
30th Jul 2026
Last updated: 30th Jul 2026
The traditional React Native feedback loop is not fast. Save a file, wait for Metro to transform the module graph, watch the packager reconcile the bundle, listen for the phone to reload. A cold start on a fresh project can take a full minute. Even the incremental cycle — the one that's supposed to feel snappy — is measured in seconds.
For a chat-driven app builder, that's a non-starter. When our AI writes a screen, users expect it to render while they're still watching the code stream. So we did something aggressive: we deleted the build step. Not deferred it, not cached it — deleted it. There is no packager running on a server. There is no Metro process on your laptop. The entire React Native bundler runs inside a Web Worker in your browser, and it produces a running preview in about 150 milliseconds after a file change.
This post is the engineering story of that decision. What we ripped out, what we replaced it with, and the specific design choices — a virtual file system, a debounced update queue, per-file error stubs — that make the react native live preview actually feel instant.
Feedback loops set the ceiling on how fast you can iterate. Cutting the build step out of the loop is the biggest win available. — Photo by Christopher Gower on Unsplash
The problem with the standard Expo dev loop
Before explaining what we built, it's worth being specific about what we replaced.
A normal Expo project ships with Metro, Facebook's bundler for React Native. Metro sits on your machine as a Node process. It watches the file system, transforms JSX and TypeScript through Babel, resolves the module graph, and serves the resulting bundle to a running app over a local HTTP server. When you edit a file, Metro recomputes only the changed modules and pushes a hot-module-reload payload to the app via a WebSocket.
That architecture is excellent for a single developer on a workstation. It has three properties that make it wrong for a cloud-based AI builder:
- It needs a server per user. Every project session would need a Node process running Metro somewhere. At scale, that is a container-per-user infrastructure bill.
- Cold starts are slow. Spinning up Metro, warming the module cache, and producing a first bundle takes 30-90 seconds. If a user opens a project, waits a minute for Metro to boot, and only then sees a preview, the product feels broken.
- It doesn't know about AI streams. Metro assumes file changes come from a human typing. When our AI writes ten files in three seconds, Metro will happily kick off ten rebuild cycles and flicker the preview through each one.
We considered wrapping Expo Snack, deploying Metro to Firecracker microVMs, and running Metro serverless on Lambda. All of them retained the fundamental cost — a bundler process outside the browser. So we asked the opposite question: what if the bundler is the browser?
Moving the bundler into a Web Worker
The core of the system is a single Web Worker that ships a browser-compatible port of Metro. In our codebase this lives at src/modules/file/bundler.worker.ts, and it's ~1,100 lines of orchestration around two npm packages doing the heavy lifting:
browser-metro— a port of Metro's core (module graph, resolver, transformer pipeline, incremental bundling) that runs without Node APIs.@babel/standalone— Babel compiled for the browser, used for JSX/TypeScript transpilation and to inject the React Refresh runtime into every module.
The worker is a black box with a simple message protocol. The main thread sends it a watch-start message with the initial file map, and it responds with a watch-ready event containing a fully-bundled JavaScript string. From that point on, the worker listens for watch-update messages (containing a diff of changed files) and responds with either a hmr-update payload (for the fast path) or a watch-rebuild payload (for structural changes like new routes).
Running this in a Web Worker rather than the main thread is not decorative. Bundling a moderately-sized Expo Router project takes 200-800ms of CPU time on a mid-range laptop. If that ran on the main thread, every rebuild would jank the editor UI, drop typing frames, and make the Redux store sluggish. Off-thread, the worker can compile freely while the user keeps editing.
Off-thread compilation keeps the editor responsive even during full-bundle rebuilds. — Photo by Emile Perron on Unsplash
The virtual file system that feeds it
The worker never touches disk. There is no fs in the browser. Instead, the source of truth for a project's code is an in-memory VirtualFileSystem (src/modules/worker/lib/virtual-file-system/), a plain JavaScript object mapping file paths to string contents with a lightweight watcher API.
When the AI writes a file, the tool call resolves to vfs.addFile(path, contents). When a user makes an edit through the point-and-edit feature, the same API is called. Every mutation notifies subscribed watchers, and one of those watchers is the bundler bridge in src/modules/file/instances.ts, which forwards the change to the worker.
This design lets us treat every source of code changes — the AI, the code editor, the visual editor, the version-history restore feature — identically. The bundler doesn't know or care which one wrote the file. This is the same architectural pattern browser-based IDEs like StackBlitz WebContainers use, but the file system is intentionally simpler because we're not emulating a POSIX shell — just serving files to a bundler.
From tokens to pixels: the full data flow
Here's what actually happens between the moment an AI model emits a token and the moment the resulting screen appears in the preview iframe:
- AI streams a tool call. The AI SDK surfaces a
writeFiletool invocation from the model's streaming output. The Redux thunk insrc/modules/editor/store/thunks/editorThunks.tsreceives it. - VFS mutation. The thunk calls
vfs.addFile(path, contents). The virtual file system notifies its watchers. - Bridge enqueues the change. The bridge in
src/modules/file/instances.tsdoesn't send the change immediately. It accumulates the diff in a pending map and starts (or resets) a 150ms debounce timer. - Debounce fires. After 150ms of silence,
flushPendingChanges()posts awatch-updatemessage to the worker with the accumulated file diff. - Worker rebuilds.
browser-metrocomputes the module graph delta, transforms only the affected files through Babel, and produces either an HMR patch (modules changed, no new routes) or a fresh full bundle (a route was added, deleted, or renamed). - Main thread receives the result.
onHmrUpdateposts the HMR payload directly to the running iframe viapostMessage.onBundlewraps the fresh bundle in a minimal HTML document (buildBundleHtmlinsrc/modules/file/bundle-html.ts), creates ablob:URL, and points the iframe at it. - Iframe applies the update. The runtime inside the iframe — a small script we inject — receives the HMR payload, executes the new module factories, and calls
React.__ReactRefresh.performReactRefresh(). Component state is preserved. The screen re-renders in place.
End-to-end, the fast path (an edit that triggers HMR) completes in 100-300ms. The slow path (a full rebuild triggered by a new route file) takes 400-900ms depending on project size. Both are dramatically faster than the seconds-to-minutes cycle of a traditional Expo dev server.
Why 150 milliseconds of debounce matters
The debounce in step 3 is not a rounding-error optimization. It's load-bearing.
When a language model streams a complex screen, it may issue five or ten writeFile tool calls in quick succession — one for the screen, one for a component, one for a hook, one for styles, and so on. Without debouncing, each write would trigger its own rebuild-and-postMessage cycle. The iframe would flicker through five broken intermediate states before landing on the finished one. Users would see incomplete UIs render and disappear, and the whole experience would feel jittery.
A 150ms debounce is long enough to coalesce a burst of AI writes into a single rebuild, and short enough that a human typing in the code editor doesn't perceive a delay. We landed on 150ms empirically after trying 50ms (too many flickers), 300ms (typing felt laggy), and event-based batching (harder to reason about). This is the kind of parameter you only tune correctly if you've watched real users use the product.
Making React Native run in a browser at all
None of the above would matter if React Native components couldn't render in a browser. That's the job of two things.
The first is react-native-web, Nicolas Gallagher's compatibility layer that reimplements <View>, <Text>, <Image>, Animated, and the rest of React Native's primitive components as DOM elements. Our worker's expoWebPlugin aliases every import 'react-native' to import 'react-native-web' at bundle time, transparently. The generated code doesn't know or care.
The second is a set of shims for the parts of the Expo ecosystem that don't have first-class web support. Our bundler-shims.ts module provides in-memory stand-ins for expo-constants, expo-router, react-native-reanimated, react-native-webview, and nativewind. Some of these are just enough to prevent import errors; others (like the NativeWind shim that maps className to Tailwind classes) are real, working implementations. Tailwind itself is injected via a CDN link in the iframe HTML wrapper, so utility classes light up immediately.
Expo Router deserves a special note. Its file-system-based routing model normally depends on Metro's require.context and Node file APIs. In the browser, we synthesize an equivalent require.context at bundle time by scanning the virtual file system for files under /app/, generating a mapping object, and injecting a synthetic entry point that requires all of them. When a new route file appears — typically because the AI just created one — the worker detects the structural change and triggers a full rebuild rather than an HMR patch, so the new route is discoverable to the router.
react-native-web plus a small shim layer lets React Native code render as DOM without touching the generated source. — Photo by Rami Al-zayat on Unsplash
Why one broken file doesn't kill the whole preview
Anyone who has used an AI code generator knows the model will, occasionally, emit code that doesn't compile. A missing brace. An unresolved import. A stray tool-call artifact in the middle of a JSX expression. In a traditional bundler, one broken file means the entire bundle fails and the app shows a red error overlay.
We couldn't accept that. If a user has ten screens working and the AI breaks one, the other nine should keep working while they fix the tenth.
The mechanism is per-file error stubbing. The errorEnhancedTransformer in the bundler wraps every file transformation in a try/catch. On failure — a syntax error, a resolve error, a Babel transform error — the file is replaced in the module graph with a BrokenComponentStub: a small React component that renders a red card with the error message, the file path, and (where possible) the offending line. The bundle succeeds; every other file is unaffected; and the broken screen displays an in-context error card wherever it would have rendered normally.
This is a small change with an outsized effect on how the product feels. Users can navigate through a partially-broken app, keep testing the working screens, and see exactly which file needs fixing without the "everything is on fire" experience of a full compilation failure.
We layer a second recovery mechanism on top. A RecoveryOrchestrator reads the current set of broken files from a shared timeline, and can offer "Fix with AI" as a one-click action that feeds the error message back to the model as a follow-up prompt. Most syntax errors self-heal within a single follow-up.
Source maps: mapping runtime errors back to source
The bundle we ship to the iframe is a single JavaScript string with every module inlined. If a runtime error fires inside SettingsScreen.tsx, the browser's stack trace by default points at line 4,271 of blob:xyz — useless for the user.
We solve this the standard way: inline source maps. The bundler emits a base64-encoded source map at the end of the bundle, and a small VLQ decoder runs inside the iframe. When window.onerror fires, we resolve the runtime coordinates back to app/settings.tsx:23, surface that in the timeline, and offer to auto-fix it. The user never sees the bundled coordinates.
Inline source maps keep the whole preview self-contained in a single blob URL — no separate .map fetch, no CDN, no server round-trip.
What "no build step" actually costs
Every architectural choice has a bill attached. Being honest about ours:
Client CPU. A full bundle of a 40-file Expo project uses 400-800ms of a single core on an M2 Mac. On a low-end Chromebook or a 5-year-old Android tablet, it's 2-3 seconds. We've spent real time optimizing the transformer pipeline for this reason, and there's more room to grow.
Memory. Holding the parsed module graph, the source strings, the compiled output, and the source maps in browser memory adds up. A large project session uses 150-300MB. It's fine on modern hardware but not free.
First-load latency for the bundler bundle itself. browser-metro plus @babel/standalone plus our shims weigh roughly 2MB gzipped. That's downloaded once per session and cached, but the first load takes a moment. We accept it because it eliminates every subsequent cost.
What we get in return. Zero per-user infrastructure. Sub-second rebuilds. HMR that preserves component state through code edits. A preview that works offline once loaded. And the ability to hand the entire code + preview experience to a user who has never installed Node.
The full editor experience — chat, code editor, preview, deploy — sits inside one browser tab with no server-side build infrastructure behind it. That is the payoff.
People Also Ask
How does React Native run in a browser?
React Native runs in a browser through react-native-web, a compatibility layer that reimplements React Native's core components (View, Text, Image, Animated) as DOM elements. Bundlers alias import 'react-native' to 'react-native-web' at build time so unmodified React Native source code renders as HTML. Our stack pairs this with a client-side bundler and shims for Expo modules.
What is Metro bundler?
Metro is Facebook's JavaScript bundler built specifically for React Native. It transforms JSX and TypeScript through Babel, resolves the module graph, and serves incremental updates over HTTP. Metro normally runs as a Node.js process on the developer's machine. browser-metro is a port of its core that runs entirely in the browser.
How does hot module reload work?
Hot module reload swaps updated JavaScript modules into a running app without a full page reload, preserving component state. React's Fast Refresh runtime does the swap: when the bundler sends a patch containing new module factories, Fast Refresh executes them, re-runs affected components, and rebuilds their subtrees while keeping unrelated state intact.
Can you preview an Expo app without a server?
Yes, with a client-side bundler. Traditional Expo development requires a Metro dev server, but architectures that move the bundler into the browser (using Web Workers and browser ports of Metro) can bundle Expo apps entirely on the client and render them in an iframe with react-native-web. RapidNative uses this approach.
What this makes possible
Deleting the build step wasn't the goal. It was the prerequisite for what we actually wanted: a chat interface where the gap between "describe a screen" and "see it running" is small enough to fall inside a single held thought.
That's what we're building at RapidNative — the fastest way from an idea to a working React Native app. You describe a screen; the AI writes it; the preview shows it. The instant preview is one of about a dozen engineering pieces that make that possible. Related deep-dives in this series cover how we stream AI-generated code in real time, our two-step AI pipeline, and how we handle errors in AI-generated code.
If you want to try the result — an AI app builder with zero-latency preview — start building for free. No install, no server, no build step. Describe a screen and watch it render.
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.