How I built a dev server entirely in the browser
Development•React Native••10 minutes read
Sanket Sahu
Guest Author
Learn how to use Service Workers, a VFS, ES modules, and React Refresh to power sub-100ms React Native previews for AI-assisted app building in the browser.

This is a guest post from Sanket Sahu - the co-founder of GeekyAnts and creator of gluestack. He's currently building RapidNative, an AI-powered app builder for React Native.
...
For the past year, I've been building RapidNative — an AI-powered app builder for React Native. One of the core challenges was making the preview instant. Not "fast" — instant.
When you're building an AI-powered tool that streams code in real-time, every millisecond of latency breaks the magic. The previous version of RapidNative ran a sandbox in the cloud and connected it to an iframe. It worked, but the round-trip latency was noticeable. I needed something better.
So I did what any reasonable engineer would do: I rebuilt an entire dev server inside the browser.
The goal: instant speed, no CLI
Here's what I wanted to achieve:
- Zero latency between code change and preview update
- No
npm run devor any CLI commands - No external server for bundling or compilation
- Full HMR support with state preservation
- Expo Router support out of the box
The result? As I type in the editor, the preview updates in under 100ms with React state preserved.
The whiteboard diagram showing how traditional dev servers map to browser-native APIs
Understanding the traditional dev server
Before diving into the browser implementation, let's understand what happens when you run npx expo start or npm run dev:
The traditional dev server flow: Code Editor → File System → Metro Bundler → Dev Server → Browser
The dev server does several things:
- Watches the file system for changes
- Transpiles TypeScript/JSX to JavaScript
- Bundles all modules into a single file (or chunks)
- Serves the bundle via HTTP
- Notifies the client of changes (HMR)
The question I asked: Can we do all of this in the browser?
The browser has everything we need
It turns out modern browsers have APIs that can replicate every piece of a dev server:
Let me break down each piece.
1. Service worker as the dev server
The Service Worker is the cornerstone of this architecture. Instead of running a server on localhost:3000, I intercept fetch requests using a Service Worker.
Any request to /_sw/render/{tabId}/{projectId}/* gets handled by the Service Worker, which serves files from the Virtual File System:
// Simplified from src/modules/worker/index.tsself.addEventListener('fetch', (event) => {const url = new URL(event.request.url);// Only intercept /_sw/* routesif (!url.pathname.startsWith('/_sw')) {return;}event.respondWith(handleSWRequest(event.request));});async function handleSWRequest(request: Request): Promise<Response> {const url = new URL(request.url);// Parse: /_sw/render/{tabId}/{projectId}/{...filePath}const [, , , tabId, projectId, ...filePathParts] = url.pathname.split('/');const filePath = '/' + filePathParts.join('/');// Get file from Virtual File Systemconst file = await destVFS.getFileInfo(filePath);if (!file) {return new Response('Not found', { status: 404 });}return new Response(file.content, {headers: {'Content-Type': file.mimeType,'Cache-Control': 'no-cache',},});}
The Service Worker also handles RPC communication with the main thread via MessageChannel, enabling bidirectional communication for file operations:
// RPC system for main thread ↔ service worker communicationself.addEventListener('message', async (event) => {const { method, args, callId } = event.data;const port = event.ports[0];const handler = rpcHandlers[method];const result = await handler(...args);port.postMessage({ callId, result });});const rpcHandlers = {async listFiles(tabId, projectId) { /* ... */ },async getFile(tabId, projectId, path) { /* ... */ },async updateFile(tabId, projectId, path, content) { /* ... */ },async deleteFile(tabId, projectId, path) { /* ... */ },};
2. Virtual File System (VFS)
Since browsers don't have direct file system access (OPFS exists but has limitations), I built a Virtual File System class that mimics Node's fs module:
// Simplified from src/modules/worker/lib/virtual-file-system/src/VirtualFileSystem.tsinterface VirtualFile {path: string;content: string | Blob;mimeType: string;size: number;lastModified: number;}class VirtualFileSystem {private namespace: string;private watchers: Set<(event: VFSEvent) => void> = new Set();constructor(config: { namespace?: string } = {}) {this.namespace = config.namespace || 'default';}// Watch for file changes - this is our "chokidar"watch(callback: (event: VFSEvent) => void): () => void {this.watchers.add(callback);return () => this.watchers.delete(callback);}private emit(event: VFSEvent): void {this.watchers.forEach((callback) => callback(event));}async addFile(path: string, content: string | Blob, mimeType?: string): Promise<void> {const normalizedPath = this.normalizePath(path);await addFileToIndexedDB(this.namespace, {path: normalizedPath,content,mimeType: mimeType || this.detectMimeType(normalizedPath),lastModified: Date.now(),});// Emit event to trigger rebuildthis.emit({type: 'add',namespace: this.namespace,path: normalizedPath,content,});}async updateFile(path: string, content: string | Blob): Promise<void> {// Similar to addFile, but emits 'update' event}async deleteFile(path: string): Promise<void> {// Delete and emit 'delete' event}async listFiles(): Promise<VirtualFile[]> {return await listFilesFromIndexedDB(this.namespace);}}
The key insight is the dual VFS architecture:
- Source VFS: Stores raw user files (TypeScript, JSX)
- Destination VFS: Stores transformed files (JavaScript)
When a file changes in Source VFS, the watcher triggers transformation and writes to Destination VFS. The Service Worker serves from Destination VFS.
3. ESMify: The transformation pipeline
This is where the magic happens. ESMify is a plugin-based transformation system that converts TypeScript/JSX to browser-executable JavaScript:
// Plugin interfaceinterface ESMifyPlugin {name: string;test: (path: string) => boolean;transform: (ctx: TransformContext) => Promise<string | null>;}// The ESMify pipelineclass ESMify {private plugins: ESMifyPlugin[] = [];use(plugin: ESMifyPlugin): this {this.plugins.push(plugin);return this;}async transform(path: string, content: string): Promise<string> {let result = content;for (const plugin of this.plugins) {if (plugin.test(path)) {result = await plugin.transform({ path, content: result });}}return result;}}// Usageconst esmify = new ESMify();esmify.use(new ReactPlugin()); // Babel JSX/TS transformationesmify.use(new ReactHMRPlugin()); // React Refresh registrationesmify.use(new NativeWindPlugin()); // Tailwind/NativeWind support
The core transformation uses @babel/standalone:
import * as Babel from '@babel/standalone';class ReactPlugin implements ESMifyPlugin {name = 'react';test(path: string): boolean {return /\.(tsx?|jsx?)$/.test(path);}async transform(ctx: TransformContext): Promise<string> {const result = Babel.transform(ctx.content, {filename: ctx.path,presets: [['typescript', { isTSX: true, allExtensions: true }],['react', { runtime: 'automatic' }],],plugins: [// Path alias resolution(@/ → /src/)[aliasPlugin, { aliases: { '@': '/src' } }],],sourceMaps: 'inline',});return result.code;}}
Key insight: Unlike Metro, there's no bundling — only transpilation. Each file remains a separate ES module.
The Vite connection: Why no bundling works
If this architecture sounds familiar, it's because it shares DNA with Vite. Both RapidNative and Vite leverage the same core insight: modern browsers natively support ES modules, so why bundle during development?
Traditional bundlers like Metro or Webpack concatenate all your modules into a single (or few) bundle files. This made sense when browsers didn't support ES modules natively. But today, browsers can:
- Load ES modules directly via
<script type="module"> - Resolve bare imports using Import Maps
- Cache individual modules separately
The benefits of this unbundled approach:
- Faster startup: No need to bundle everything before serving
- Faster HMR: Only the changed module needs to be reloaded
- Better caching: Unchanged modules stay cached
- Simpler architecture: No complex dependency graph bundling
Where RapidNative differs from Vite:
Essentially, RapidNative is like Vite, but running entirely in the browser with no CLI or Node.js required.
4. ES Modules + Import Maps = No bundling
Here's where things get interesting. Instead of bundling dependencies like Metro does, I use native ES Modules with Import Maps:
<script type="importmap">{"imports": {"react": "https://esm.rapidnative.com/react@19.1.1?dev&bundle=true","react-dom": "https://esm.rapidnative.com/react-dom@19.1.1?dev&bundle=true","react-dom/client": "https://esm.rapidnative.com/react-dom@19.1.1/client?dev","react-native": "https://esm.rapidnative.com/react-native-web@0.21.1?dev","expo-router": "/packages/rapidnative-expo-router/index.js"}}</script>
When user code imports react:
import React from 'react';
The browser resolves it via the import map to the CDN URL. No bundling required.
The esm.rapidnative.com CDN
Standard esm.sh doesn't work well with React Native packages because they ship untranspiled (JSX stays as JSX). So I forked esm.sh to create esm.rapidnative.com which:
- Auto-resolves
.web.jsfiles for React Native Web compatibility - Skips native modules that can't run in browsers
- Maps
react-native→react-native-webautomatically - Handles React Native-specific package quirks
// Special package configurationconst SPECIAL_PACKAGES: Record<string, SpecialPackageConfig> = {'react-native': {packageNameOverride: 'react-native-web',fixedVersion: '0.21.1',includeDev: true,},'expo-router': {customUrl: () => '/packages/rapidnative-expo-router/index.js',},'react-native-reanimated': {customUrl: () => '/packages/react-native-reanimated-polyfill.js',},};
5. React Refresh: HMR that actually works
This was the hardest part to get right. React Refresh enables Hot Module Replacement while preserving component state.
The ReactHMRPlugin wraps every export with a registration call:
// src/modules/worker/lib/virtual-file-system/src/plugins/ReactHMRPlugin.tsclass ReactHMRPlugin implements ESMifyPlugin {name = 'react-hmr';async transform(ctx: TransformContext): Promise<string> {// Transform with Babel to add registration callsconst result = Babel.transform(ctx.content, {presets: ['typescript', 'react'],plugins: [[this.createExportsRegistrationPlugin(ctx.path)]],});// Prepend React Refresh runtimeconst preamble = `import RefreshRuntime from "https://esm.rapidnative.com/react-refresh/runtime";window.$RefreshReg$ = (type, id) => {RefreshRuntime.register(type, id);};function __register_export__(id, value) {if (typeof value === 'function') {window.$RefreshReg$(value, id);}return value;}`;return preamble + result.code;}}
The Babel plugin transforms exports like this:
// Before transformationexport default function App() {return <View>...</View>;}// After transformationconst _default = function App() {return <View>...</View>;};__register_export__('app/index.tsx::default', _default);export default _default;
When a file changes, the Service Worker notifies the iframe. The iframe then dynamically re-imports the changed module before triggering React Refresh:
// After transformation completes, notify the iframeiframe.contentWindow.postMessage({type: 'HMR_UPDATE',files: changedFiles,}, '*');// In the iframe - handle HMR updateswindow.addEventListener('message', async (event) => {if (event.data.type === 'HMR_UPDATE') {const { files } = event.data;// Dynamically re-import each changed module to get the new codefor (const filePath of files) {// Add cache-busting timestamp to force browser to fetch fresh moduleconst moduleUrl = `${filePath}?t=${Date.now()}`;try {// This fetches the updated module from the Service Workerawait import(moduleUrl);} catch (error) {console.error(`Failed to reload module: ${filePath}`, error);}}// Now that new modules are loaded, tell React Refresh to update componentsRefreshRuntime.performReactRefresh();}});
The key here is the dynamic import() call. ES modules are normally cached by the browser, so simply calling performReactRefresh() wouldn't pick up the new code. By dynamically importing the module with a cache-busting query parameter, we force the browser to fetch the fresh version from the Service Worker, which serves the newly transformed file from the Destination VFS.
The result: state is preserved across code changes. Click a button 5 times, change the button text, and the counter stays at 5.
6. The complete data flow
Here's how everything connects:
The complete browser-based dev server: Monaco Editor communicates with Service Worker via RPC, which manages VFS and ESMify transformation, serving files to the Preview Iframe
How this compares to Snack
Note: Expo's Snack is another browser-based React Native playground. Since this article is on the Expo blog, it's worth addressing the differences.
Snack and RapidNative solve different problems with different architectural trade-offs:
Snack's architecture is optimized for running on actual iOS/Android devices. It uses a customized SystemJS pipeline for module loading and transpilation, and sends code via WebSockets to Expo Go. The trade-off is that the code is reloaded in a more primitive way and may cause full component tree re-renders.
RapidNative's architecture is currently optimized for instant browser feedback during AI-assisted development. Native ES modules and React Refresh enable sub-100ms updates with state preservation — critical when an AI is streaming code changes in real-time.
Looking ahead: RapidNative will also support native device preview via Expo Go. The browser-based runner described in this article is one piece of a larger system. When native device support lands, developers will get the best of both worlds: instant browser preview during rapid iteration, plus the ability to test on real devices when needed.
Both approaches are valid; they're just optimized for different phases of the development workflow.
What's next?
The current implementation handles the core dev server functionality, but there's more to build:
TypeScript Language Server (Coming Soon)
Currently, TypeScript files are transpiled but don't show type errors or autocomplete. The plan is to run the TypeScript language server in a Web Worker:
Monaco Editor will communicate with a Web Worker running the TypeScript language server for type checking and autocomplete
Lightning FS / Isomorphic Git
We're integrating isomorphic-git with Lightning FS for browser-native Git operations. This will enable:
- Full version control in the browser
- Undo/redo via Git commits
- Branch management without a server
Native Device Support
As mentioned in the Snack comparison, native device preview is on the roadmap. The goal is to support Expo Go so developers can test on real iOS and Android devices without sacrificing the instant browser preview experience.
Conclusion
Building a dev server in the browser seemed impossible at first, but modern browser APIs make it surprisingly feasible:
- Service Workers replace the HTTP server
- Virtual File System + IndexedDB replace the file system
- Babel Standalone handles transpilation
- ES Modules + Import Maps eliminate bundling (like Vite)
- React Refresh enables true HMR
The result is a development experience that feels native: instant feedback, state preservation, and zero CLI commands.
If you're building browser-based development tools, I hope this deep dive helps. The architecture is complex, but the payoff — instant, delightful developer experience — is worth it.
Resources:
- RapidNative
- Video: How I Built a Dev Server in the Browser
- Vite - The inspiration for unbundled development
- esm.sh - ES Module CDN
- React Refresh - HMR runtime
- Expo Snack - Expo's browser-based playground



