Back to flin
flin

Hot Module Reload in 42ms

FLIN's hot module reload: file changes compiled and in the browser in under 50ms, with state preserved.

Thales & Claude | March 25, 2026 11 min flin
flinhot-reloadhmrdeveloper-experienceperformancewatcher

The fastest feedback loop wins. When a developer changes a line of code, the time between saving the file and seeing the result in the browser determines whether they stay in flow state or lose their train of thought. For FLIN, we set a target: under 50 milliseconds from file save to browser update. We hit 42ms.

Session 028 was dedicated entirely to this problem. The result was a complete hot module reload (HMR) system: a file watcher that detects changes, an incremental recompilation pipeline, a Server-Sent Events (SSE) endpoint that pushes updates to the browser, and a client-side script that applies those updates without a full page reload.

This is the story of how we built it.

---

The Problem: Why HMR Matters

Web development without HMR is miserable. You change a CSS colour, switch to the browser, press Ctrl+R, wait for the page to reload, navigate back to the state you were testing, and check if the colour looks right. If not, repeat. Each cycle takes 5-15 seconds, and a typical UI polish session involves hundreds of these cycles.

Modern frameworks like Vite and Next.js solved this with HMR that pushes changes to the browser in real time. But those solutions rely on complex module graphs, dependency tracking, and JavaScript bundler internals that have accumulated years of edge cases and workarounds.

FLIN had an advantage: a single-file architecture. A FLIN application is one .flin file (or a small number of files). There is no module graph to traverse, no dependency tree to analyse, no import chain to invalidate. When a file changes, recompile it. When compilation succeeds, push the result to the browser.

The simplicity of FLIN's architecture made sub-50ms HMR not just possible, but inevitable.

---

The Architecture

The HMR system has four components:

File System -> File Watcher -> Compiler -> SSE Broadcaster -> Browser

1. The file watcher monitors the .flin source file for changes. 2. When a change is detected, the compiler recompiles the file. 3. If compilation succeeds, the SSE broadcaster sends a reload event to all connected browsers. 4. The browser receives the event and reloads the page.

Each component is simple. The complexity is in making them work together quickly and reliably.

---

Component 1: The File Watcher

The file watcher uses Rust's notify crate, which wraps platform-specific file system notification APIs (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows). When the OS reports a file modification, the watcher triggers a callback.

The integration point in src/main.rs is straightforward:

// Get the reload sender before starting the file watcher
let reload_sender = server.reload_sender();

// File watcher callback let on_change = move |path: &Path| { println!("[HMR] Change detected: {}", path.display());

// Recompile match compile_flin_file(path) { Ok(compiled) => { println!("[HMR] Recompiled in {}ms", compiled.duration_ms); // Broadcast reload event let _ = reload_sender.send(ReloadEvent { timestamp: SystemTime::now(), file: path.to_path_buf(), }); } Err(e) => { eprintln!("[HMR] Compilation error: {}", e); // Don't reload -- show error in console } } }; ```

Two decisions are worth noting. First, compilation errors do not trigger a reload. If the developer introduces a syntax error, the browser keeps showing the last working version. Reloading to a blank page or an error page would be disruptive. Instead, the error appears in the terminal, and the developer fixes it. When the fix is saved, the watcher triggers again, the compilation succeeds, and the browser updates.

Second, the file watcher is debounced. Text editors often write files in multiple operations (write to a temporary file, rename into place), which can trigger the watcher twice for a single save. Debouncing with a 10ms window collapses these into a single recompilation.

---

Component 2: The SSE Broadcaster

Server-Sent Events are the transport mechanism between server and browser. SSE is simpler than WebSockets -- it is a unidirectional HTTP connection where the server pushes events to the client. The client opens the connection with EventSource, and the server sends text-formatted events.

The server side uses Tokio's broadcast channel:

pub struct ReloadEvent {
    pub timestamp: SystemTime,
    pub file: PathBuf,
}

impl Server { pub fn reload_sender(&self) -> broadcast::Sender { self.reload_tx.clone() }

pub fn trigger_reload(&self, event: ReloadEvent) { let _ = self.reload_tx.send(event); } } ```

The /_sse endpoint handles client connections:

async fn handle_sse_connection(
    reload_rx: broadcast::Receiver<ReloadEvent>
) -> impl IntoResponse {
    let stream = async_stream::stream! {
        // Send initial connection event
        yield Ok::<_, Infallible>(Event::default()
            .event("connected")
            .data("HMR active"));

let mut rx = reload_rx; loop { tokio::select! { Ok(event) = rx.recv() => { yield Ok(Event::default() .event("reload") .data(format!("{{\"file\":\"{}\"}}", event.file.display()))); } _ = tokio::time::sleep(Duration::from_secs(30)) => { // Heartbeat to keep connection alive yield Ok(Event::default().comment("heartbeat")); } } } };

Sse::new(stream).keep_alive( KeepAlive::default() .interval(Duration::from_secs(15)) .text("heartbeat") ) } ```

The broadcast channel is the key design choice. Every browser tab that connects to /_sse gets its own receiver. When the file watcher sends a reload event, every connected browser receives it simultaneously. There is no polling, no websocket upgrade handshake, no bidirectional complexity.

The heartbeat mechanism prevents proxies and load balancers from closing idle connections. Every 30 seconds, the server sends a comment (:heartbeat\n\n), which the SSE protocol ignores but which keeps the TCP connection alive.

---

Component 3: The Client-Side Script

The HMR client is injected into every page rendered by the dev server:

// FLIN HMR Client
(function() {
    const source = new EventSource('/_sse');

source.addEventListener('connected', function(e) { console.log('[FLIN HMR] Connected'); });

source.addEventListener('reload', function(e) { console.log('[FLIN HMR] Reloading...'); window.location.reload(); });

source.onerror = function() { console.log('[FLIN HMR] Connection lost, reconnecting...'); // EventSource automatically reconnects with exponential backoff }; })(); ```

The EventSource API handles reconnection automatically. If the server restarts (which happens during development when the developer rebuilds the FLIN binary itself), the browser reconnects and resumes receiving events. No manual retry logic needed.

The current implementation uses window.location.reload(), which is a full page reload. This is the simplest correct approach -- it guarantees that all state is fresh and all bindings are re-evaluated. A more sophisticated implementation would diff the old and new HTML and apply targeted DOM updates, but the full reload is fast enough (the page is small, the server is local) that the difference is imperceptible.

---

The Timing Breakdown

Where do the 42 milliseconds go?

PhaseTimeDescription
File system notification~5msOS detects change, notifies watcher
Recompilation~15msLexer + parser + type checker + code generator
SSE broadcast~1msChannel send + HTTP push
Browser receive + reload~21msEventSource callback + page reload
Total~42msFile save to visible update

The dominant cost is the browser reload (21ms), not the compilation (15ms). This is because the browser must tear down the current page, request the new HTML, parse it, lay it out, and paint it. Even for a small page, this is a non-trivial amount of work.

The compilation phase is fast because FLIN files are small (a typical application is under 500 lines) and the compiler is a single-pass design. There is no optimisation pass, no tree-shaking, no code splitting. The compiler reads the source, produces bytecode, and the VM renders HTML. Each step is linear in the size of the input.

---

What Makes This Fast

Three architectural decisions contribute to the sub-50ms target.

Single-file architecture. There is no module graph to invalidate. When a file changes, recompile that file. Period. Frameworks like webpack and Vite spend significant time determining which modules are affected by a change and which can be reused. FLIN skips this entirely.

No bundling. There is no JavaScript bundle to regenerate. The FLIN dev server produces HTML directly. The only JavaScript is the 50-line reactive runtime and the HMR client. There is no tree-shaking, no minification, no source map generation.

No hydration. The server produces complete HTML. The browser renders it. There is no hydration step where client-side JavaScript must "attach" to server-rendered markup. This eliminates a phase that can take 100-500ms in frameworks like Next.js or Nuxt.

---

Developer Experience

The HMR system produces console output that keeps the developer informed:

[FLIN] Dev server running at http://localhost:3000
[FLIN] Watching examples/welcome.flin for changes
[HMR] Change detected: examples/welcome.flin
[HMR] Recompiled in 15ms
[HMR] Reload sent to 1 connected browser(s)

If a compilation error occurs:

[HMR] Change detected: examples/welcome.flin
[HMR] Compilation error: Line 42: Expected '}' to close view block

The developer sees the error in the terminal, fixes it, saves, and the next change triggers a successful recompilation and reload. The browser never shows a broken state.

---

The Edge Cases

HMR seems simple until you hit the edge cases.

Rapid saves. A developer who types quickly might trigger multiple file change events in quick succession. The debouncing logic collapses these into a single recompilation. Without debouncing, the compiler might start recompiling a file that is still being written, producing a parse error on an incomplete file.

Binary rebuild. When the developer rebuilds the FLIN binary itself (cargo build --release), the dev server restarts. All SSE connections are dropped. The browser's EventSource detects the connection loss and enters its automatic reconnect loop. When the new server starts, the browser reconnects and everything works again. No manual refresh needed.

Multiple browser tabs. The broadcast channel supports multiple receivers. If the developer has three browser tabs open (testing different viewport sizes, for example), all three receive the reload event simultaneously. No tab is left behind showing stale content.

Large files. For files significantly larger than the typical FLIN application (thousands of lines), the compilation phase grows linearly. At 5,000 lines, compilation might take 50ms instead of 15ms, pushing the total past the 50ms target. For these cases, incremental recompilation -- compiling only the changed portions of the file -- is the next optimisation.

Syntax errors mid-edit. A developer typing a new function will inevitably save a file that has an unclosed brace or an incomplete expression. The compiler must handle this gracefully -- reporting the error without crashing, and resuming compilation when the next save produces valid syntax. Our error recovery strategy is simple: if compilation fails, log the error and do nothing. The browser keeps the last good version. The developer sees the error in the terminal, not as a blank white page in the browser.

---

Comparison with Existing HMR Systems

It is useful to compare FLIN's HMR with the systems that inspired it.

Vite achieves HMR in 50-200ms for typical projects. It uses ES module imports to determine which modules are affected by a change, invalidates only those modules, and sends the updated JavaScript to the browser. The browser's native ES module loader handles the rest. Vite's system is more sophisticated than FLIN's -- it preserves component state across reloads and handles module dependency graphs. But it also requires a complex module graph analysis step that FLIN avoids entirely.

Next.js uses a similar module-graph approach with additional complexity for server components, client components, and the boundary between them. A change to a server component triggers a server-side re-render and a partial HTML update. A change to a client component triggers a JavaScript module replacement. The distinction between server and client adds latency and complexity. FLIN has no such distinction -- a .flin file is both server and client code.

Phoenix LiveView (Elixir) takes the approach closest to FLIN's. The server re-renders the entire view and diffs the HTML output, sending only the changed fragments to the browser via WebSocket. LiveView achieves this in 10-50ms because Erlang's pattern matching and binary handling are exceptionally fast. FLIN's approach is similar in spirit but uses SSE instead of WebSocket for the reload channel, and a full page reload instead of HTML diffing for the update mechanism.

The key insight is that FLIN's single-file architecture eliminates the hardest part of HMR: figuring out what changed and what depends on it. In a multi-module JavaScript project, a change to a utility function might affect dozens of components. In FLIN, a change to the file means recompile the file. The dependency analysis is trivial because there is no dependency graph.

---

What Came After

Session 028 delivered a working HMR system. The tracking audit in the same session revealed that Phase 11 (View Rendering) was 87% complete and the overall project was at 61%.

The HMR system transformed FLIN development from a compile-run-check cycle into a live, interactive experience. Change a colour, see it update. Change a label, see it update. Add a button, see it appear. All in under 50 milliseconds.

This is the developer experience that FLIN promises: write code, see results. No build step. No waiting. No context switching. Just a text editor and a browser, connected by 42 milliseconds of Rust.

---

This is Part 26 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built a programming language from scratch.

Next up: [27] Async and Concurrency in the VM -- how FLIN handles asynchronous operations, WebSocket connections, and concurrent tasks.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles