Back to Hub
ENGINEERING •
By Yash Panchal · Last updated:

HMR Without a Bundler: How NitroIDE's Instant Preview Updates Actually Work

Type a character in Vite and the browser updates before your finger leaves the key. It feels like magic, but it isn't — it's a dev server, a WebSocket, a module graph, and a client runtime, all cooperating to patch the running page instead of reloading it.

NitroIDE does the same trick with none of that. No dev server. No WebSocket. No module graph. No bundler at all. The whole update loop is a debounced timer, a string concatenation, and an iframe attribute. Here's the entire thing — including the one place it cheats, honestly labeled.

The pipeline is five steps

I build NitroIDE, a browser-based IDE where your code never leaves your machine. The editor is Monaco, the preview is a sandboxed iframe, and every file lives in an in-memory virtual filesystem. The update loop starts the moment you type:

  1. Edit event. Each Monaco editor fires onDidChangeModelContent, which calls queueUpdate("html"), queueUpdate("css"), or queueUpdate("js") depending on which pane you're in.
  2. Debounce. queueUpdate clears the pending timer and sets a new one: 150ms for CSS edits, 200ms for HTML and JS. A typing burst collapses into a single update. Nothing renders per keystroke.
  3. Sync. When the timer fires, smartRun copies the three editors' current text into the virtual filesystem and saves the project to localStorage — so the preview and the saved project can never disagree.
  4. Assemble. Three tiny functions concatenate the filesystem into one document: collectHTML (index.html plus any extra .html files), collectCSS (every .css file, each labeled with a /* --- MODULE: name --- */ comment), collectJS (every .js file, same treatment). String concatenation is the entire build step.
  5. Swap. forceRun wraps the assembled code in a full HTML document — CDN links in the head, user CSS in a <style id="live-css-inject"> tag, user JS in a final <script> — and assigns it to the preview iframe's srcdoc attribute. The browser parses it and runs it. That's the whole deploy step.

No server round-trip, because there is no server. No file watcher, because the files were never on disk. The "bundler" is three loops and a template literal.

CSS gets true HMR. HTML and JS get a fast full reload.

Here's the honest split, because the word HMR gets abused.

When you edit CSS, the preview does not rebuild at all. queueUpdate takes a shortcut: it posts { type: "update-css", css: collectCSS() } to the live iframe, and the injected bridge script inside the preview replaces the <style id="live-css-inject"> tag's text with the fresh CSS. The page never reloads. Your JavaScript keeps running — timers keep ticking, form inputs keep their values, scroll position stays put, and the console panel isn't even cleared. That is real hot module replacement, implemented in about three lines.

When you edit HTML or JS, the preview rebuilds from scratch via srcdoc. Everything re-runs: the DOM is rebuilt, your script executes from the top, timers restart, form state resets, and the console clears first so stale logs can't masquerade as fresh ones. It's a full reload — just one that costs a string concatenation and a srcdoc assignment instead of a network round-trip, so it still lands in about 200ms.

I don't pretend the JS path is HMR. It isn't. But the split is the right one: CSS is the thing you tweak fifty times while staring at pixels, so CSS is the thing that gets the state-preserving path. HTML and JS edits are rarer and semantically bigger, so they get the clean-slate path.

The swap is flicker-free by construction

The naive version of the srcdoc rebuild has an obvious flaw: assigning srcdoc blanks the iframe while the new document parses, so every keystroke would flash the preview white. The fix is a double-buffered swap:

No white flash, no half-painted frames, no out-of-order previews. The user sees the preview change, never the machinery changing it.

What the sandbox allows — and what it costs

The preview iframe runs with sandbox="allow-scripts allow-modals allow-popups allow-forms" — notably, no allow-same-origin. I removed that during the v25 security audit, because same-origin preview code could read the parent's localStorage. The preview still runs your JavaScript and renders your page, but on an opaque origin, talking to the parent only through postMessage (console logs, errors, state-watcher updates, the CSS hot-patch).

That isolation is also why the CSS hot-patch works so cleanly: the parent can't reach into the iframe's DOM, so the update has to be a message, and a message carrying "here is the new stylesheet" is exactly the right granularity.

The honest ceiling

Every tool should state its ceiling, so here it is:

Why this shape, and not a real HMR runtime?

Because the constraints chose it. A browser IDE with no server can't run a dev server; an IDE that opens in one click can't ask you to install a watcher; an IDE whose files live in memory doesn't need one. The moment I accepted "the build step is string concatenation, the deploy step is srcdoc", every piece fell into place: debounce instead of a watcher, postMessage instead of a socket, a hidden-iframe swap instead of a virtual DOM diff.

The lesson I keep relearning: the fastest feedback loop isn't the one with the most sophisticated machinery. It's the one with the fewest moving parts between your keystroke and the pixels. Ours has five steps, zero servers, and a 200ms timer — and it has never once needed a config file.

Try it

NitroIDE is free, no signup, and your code runs only in your browser: nitroide.com

⭐ If you enjoyed this, star NitroIDE on GitHub: github.com/nitroideofficial/nitroide

Frequently asked questions

Does NitroIDE have real HMR?

For CSS, yes — the parent postMessages the fresh stylesheet and the preview swaps one style tag's text with no reload and no state loss. HTML and JS edits get a fast full reload instead, which is deliberately not called HMR.

Why do HTML and JS edits reset the preview's state?

Because those edits rebuild the preview from scratch via srcdoc: the DOM is rebuilt, scripts re-run from the top, timers restart, and form state resets. That clean slate is the point — you always see a fresh run.

Does NitroIDE use a dev server or bundler?

No. The build step is string concatenation of the in-memory files and the deploy step is assigning the result to the preview iframe's srcdoc attribute. There is no server, no watcher, and no config file.

Why doesn't the preview flash white on every keystroke?

Rebuilds render into a hidden iframe first; after its load event plus two requestAnimationFrame ticks, the new iframe is promoted and the old one removed. A generation counter discards stale rebuilds overtaken by newer typing.