Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Hot Reload Without a Framework

The file watcher knows what changed and what to rebuild. But after the build succeeds, you're still staring at the old page. This post adds the last mile: a WebSocket server that tells the browser exactly what happened, and a browser client that reacts accordingly — CSS swap, content refresh, or full reload. No framework. No webpack. No dev/prod artifact split.


The Problem

The watcher from the previous post solved the build side: save a file, press Enter, minimal rebuild. But the feedback loop still had a manual step:

save file → watcher detects → pipeline resolves → build runs → ... → alt-tab → F5 → scroll back to where you were

That alt-tab → F5 → scroll back is death by a thousand cuts. Over a writing session, it adds up to minutes of mechanical context-switching. Worse: if you're editing CSS, you lose scroll position and visual state on every reload.

The goal: make the browser react to builds automatically, with the right level of reload.


The Architecture

Three components, each independent and testable:

  ┌──────────┐     WebSocket      ┌──────────────┐
  │  Watcher │ ──── :4002 ────▶  │  Browser     │
  │  Machine │                    │  Client      │
  └──────────┘                    └──────────────┘
       │                                ▲
       │ callbacks                      │ HTTP :4000
       ▼                                │
  ┌───────────┐    inject on-the-fly   ┌──────────────┐
  │ Hot-Reload│ ◀──────────────────── │  Static      │
  │  Machine  │                        │  Serve       │
  └───────────┘                        └──────────────┘
  1. Hot-Reload Machine — WebSocket server lifecycle, client tracking, broadcast
  2. Static Server — serves public/ with on-the-fly <script> injection into HTML
  3. Browser Client — connects, reconnects, executes the right reload strategy

The key insight: the watcher already produces a PipelinePlan that describes exactly what was rebuilt. We just need a pure function that maps that plan to a reload strategy.


Reload Strategy Resolution

This is the heart of the system — a pure function, no side effects, fully testable:

export type ReloadStrategy = 'full' | 'css-only' | 'content' | 'toc';

export function resolveReloadStrategy(plan: PipelinePlan): ReloadStrategy {
  // JS/TS change → full reload (scripts need re-execution)
  if (plan.tsTranspile || plan.jsBundle) return 'full';

  // CSS-only change → inject stylesheet without full reload
  if (plan.cssBundle && !plan.regenAllHtml && !plan.tocRebuild) return 'css-only';

  // TOC-only rebuild → reload TOC data
  if (plan.tocRebuild && !plan.cssBundle) return 'toc';

  // Content-only → reload via SPA fetch
  if (plan.regenSpecificPages.length > 0 && !plan.regenAllHtml && !plan.cssBundle)
    return 'content';

  // Template, site-config, renderer-lib, images, fonts → full
  return 'full';
}

The PipelinePlan comes from the watcher's resolvePipeline() function. It already tells us whether the build touched TypeScript, CSS, specific markdown pages, the TOC, templates, images, or fonts. The strategy function just reads those flags and picks the lightest possible reload.

What each strategy does in the browser:

Strategy Browser Action Scroll Position Visual State
full location.reload() Lost Reset
css-only Cache-bust <link> stylesheets Preserved Updated
content Re-fetch page via SPA mechanism Preserved Updated
toc Re-fetch toc.json, rebuild sidebar Preserved Updated

The CSS-only reload is the biggest win. When you're tweaking colors, spacing, or animations, seeing the result without losing your scroll position and DOM state is a qualitative improvement.


The WebSocket Server — A State Machine

Same pattern as every other machine in this project: closure-based factory, callback-injected side effects, guard clauses.

export type HotReloadState = 'stopped' | 'listening';

export interface HotReloadMachine {
  start: (port: number) => void;
  stop: () => void;
  broadcast: (message: HotReloadMessage) => void;
  getState: () => HotReloadState;
  getClientCount: () => number;
  isEnabled: () => boolean;
  toggleEnabled: () => boolean;
}

Two states. Start creates a WebSocketServer on the given port. Stop closes it. broadcast() sends a JSON message to all connected clients — unless enabled is false (toggled with l in the watcher TUI), in which case it's a no-op. Connections stay alive either way.

Message Protocol

Three message types, all JSON:

{ type: 'build-started', summary: 'css + 1 page' }
{ type: 'reload', strategy: 'css-only' }
{ type: 'build-failed', error: 'TS2304: Cannot find name ...' }

The build-started message lets the browser show a subtle indicator. The build-failed message shows a red error banner that auto-dismisses on the next successful reload.

Heartbeat

Dead connections are detected with a ping/pong heartbeat every 30 seconds. The implementation avoids the classic Node.js EventEmitter leak: instead of adding a new pong listener on every ping interval, it uses a WeakSet to track which clients are alive:

const aliveClients = new WeakSet<WsClientLike>();

function wireClient(client: WsClientLike): void {
  aliveClients.add(client);
  client.on('pong', () => { aliveClients.add(client); });
  // ...
}

function setupHeartbeat(): void {
  heartbeatInterval = setInterval(() => {
    for (const client of server.clients) {
      if (!aliveClients.has(client)) {
        client.terminate(); // missed the last ping
        continue;
      }
      aliveClients.delete(client);
      client.ping(); // must pong before next interval
    }
  }, 30_000);
}

One listener per client, forever. No accumulation, no MaxListenersExceededWarning.


The Static Server — On-the-Fly Injection

This is the piece that eliminated the dev/prod split on public/.

The Problem It Solves

The naive approach: inject a <script> tag into all HTML files in public/ at build time, controlled by a --dev flag. But this means public/ is now in one of two modes — you have to remember which, you have to rebuild to switch, and you risk deploying dev artifacts. We tried it. It was annoying.

The Solution

Replace npx serve with a built-in Node.js static server (~100 lines) that injects the hot-reload client script into HTML responses in memory, at serve-time:

export function createStaticServer(opts: StaticServerOptions): http.Server {
  const server = http.createServer((req, res) => {
    // ... resolve file path, read from disk ...

    // Inject hot-reload script into HTML responses — in memory, never on disk
    if (inject && ext === '.html') {
      const html = data.toString('utf8');
      const injected = html.replace('</body>',
        '  <script src="/js/hot-reload-client.js"></script>\n</body>');
      res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
      res.end(injected);
      return;
    }

    res.writeHead(200, { 'Content-Type': contentType });
    res.end(data);
  });
  return server;
}

The hot-reload client JS is served from the project root's js/ directory — not from public/. So public/ is always a clean, deployable directory. No --dev flag. No artifact cleanup. git diff public/ shows exactly what the production site will look like.

The Serve Architecture

The static server plugs into the existing ServerMachine via callbacks:

spawnServe(dir: string | null, port: number) {
  if (target === 'static') {
    // Built-in server with optional injection
    const server = createStaticServer({
      dir: dir || '.',
      port,
      injectHotReload: staticInjectHotReload,
    });
    server.listen(port, () => machine.ready(`http://localhost:${port}`));
  } else {
    // Dev server: still npx serve (raw markdown SPA mode)
    spawn('npx serve ...', ...);
  }
}

When the watcher starts with --serve, it sets staticInjectHotReload = true before the server starts. When you start the static server from the TUI without the watcher, injection is off — you get the same files Vercel would serve.


The Browser Client — Resilient and Silent

The client is a small IIFE (~150 lines) that self-activates only on localhost:

function init(): void {
  const host = location.hostname;
  if (host !== 'localhost' && host !== '127.0.0.1'
      && host !== '' && location.protocol !== 'file:') {
    return; // production — do nothing
  }
  connect();
}

Reconnection

Exponential backoff: 1s, 2s, 4s, 8s (capped). Resets to 1s on successful connection. No console errors — if the watcher isn't running, the client silently retries in the background.

Reload Actions

switch (msg.strategy) {
  case 'css-only':
    // Bust cache on all stylesheets — no page reload
    document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
      link.href = link.href.replace(/[?&]v=\d+/, '') + '?v=' + Date.now();
    });
    break;

  case 'content':
    // Tell the SPA to re-fetch the current page
    window.dispatchEvent(new CustomEvent('hot-reload:content', { detail: msg }));
    break;

  case 'toc':
    // Tell the SPA to re-fetch toc.json
    window.dispatchEvent(new CustomEvent('hot-reload:toc'));
    break;

  default:
    location.reload();
}

The CSS reload is the simplest and most satisfying: append ?v={timestamp} to every <link rel="stylesheet"> href. The browser re-fetches the CSS, re-renders, and your scroll position, DOM state, and open modals all survive.

For content and TOC reloads, the client dispatches CustomEvents that the SPA application listens for. This keeps the hot-reload client decoupled from the app — it doesn't import anything, doesn't know about the app's internal routing. The app decides how to handle the event.

Visual Feedback

A 10px dot in the bottom-left corner: green (connected), yellow (building), red (build failed). Fades out after 3 seconds of stable connection. A red error banner appears on build failures, showing the error message, and auto-dismisses on the next successful build.


Watcher Integration

The hot-reload system wires into the existing watcher callbacks. No new state machine states, no new events — just three broadcast() calls:

executePlan: async (plan) => {
  if (hotReload) hotReload.broadcast({ type: 'build-started', summary });
  return executePlan(plan, io, config);
},

onBuildComplete: (_ok, durationMs, plan) => {
  if (hotReload && plan) {
    const strategy = resolveReloadStrategy(plan);
    hotReload.broadcast({ type: 'reload', strategy });
  }
},

onBuildFailed: (error) => {
  if (hotReload) hotReload.broadcast({ type: 'build-failed', error });
},

The idle display shows hot-reload status and client count, and redraws when clients connect or disconnect:

  ┌─ watch ──────────────────────────────────────────────┐
  │  Watching: src/ css/ content/ fonts/ test/           │
  │  Mode: manual              Status: idle              │
  │  Server: http://localhost:4000                       │
  │  Hot Reload: on  (1 client)                          │
  │                                                      │
  │  [Enter] Build  [c] Clear  [a] Auto  [q] Quit       │
  │  [l] HR off  [f] Reload                              │
  └──────────────────────────────────────────────────────┘

l toggles broadcast suppression. f sends a force-reload to all clients without triggering a build.


Testing

The strategy resolution is a pure function — the easiest thing in the world to test:

'cssBundle only → css-only'() {
  expect(resolveReloadStrategy(makePlan({ cssBundle: true }))).toBe('css-only');
}

'tsTranspile + cssBundle → full (JS takes precedence)'() {
  expect(resolveReloadStrategy(makePlan({
    tsTranspile: true, cssBundle: true
  }))).toBe('full');
}

'regenSpecificPages only → content'() {
  expect(resolveReloadStrategy(makePlan({
    regenSpecificPages: ['content/blog/test.md'],
  }))).toBe('content');
}

The machine itself is tested with fake WebSocket server/client classes injected via createWsServer in options:

function machineWithFake(overrides = {}) {
  const { server, connectClient } = createFakeWsServer();
  const machine = createHotReloadMachine(
    { ...nullCallbacks, ...overrides },
    { createWsServer: () => server },
  );
  return { machine, server, connectClient };
}

35 tests with @FeatureTest / @Implements decorators linking to the requirements DSL. No real sockets, no real network, fully deterministic.


The Developer Experience

Before:

save → alt-tab → F5 → scroll → repeat

After:

save → press Enter → see it

Or in auto mode:

save → see it

The improvement is qualitative, not just quantitative:

  • CSS tweaks: you see the result without losing scroll position. This changes how you work — you stop batching CSS changes and start making one tweak at a time, because the feedback is instant.
  • Content editing: the markdown re-renders in place. You keep reading where you were.
  • TypeScript changes: full reload, because scripts need re-execution. But the browser does it automatically, so you're never staring at stale code.
  • Build failures: the red banner tells you what went wrong without switching windows. Fix the error, save, the banner disappears.

And because the injection happens at serve-time:

  • public/ is always deployable. No --dev flag. No cleanup step.
  • npm run build:static produces exactly what goes to production.
  • No second public-dev/ directory. No mode confusion.

What I'd Do Differently

  1. Start with the static server. The npx serve--dev flag → HTML mutation → built-in server journey took three iterations. If I'd built the static server first, the injection would have been trivial from day one.

  2. The heartbeat WeakSet pattern should be the default. Adding a listener per interval per client is a classic Node.js mistake. The WeakSet approach — mark alive on pong, check on next ping — is simpler and leak-free.

  3. CSS-only reload could be even smarter. Right now it busts all stylesheets. A future version could track which CSS file changed and only reload that one.


Summary

Component Lines Dependencies
Hot-Reload Machine ~200 ws (dev only)
Static Server ~100 Node.js http + fs
Browser Client ~150 None
Tests ~450 vitest
Total ~900 1 dev dep

The entire system is under 1000 lines. It has one dev dependency (ws). It's tested with 35 unit tests. And it turns the build-reload cycle from a manual, attention-draining ritual into something that just happens.

That's the kind of developer experience investment that pays for itself in the first hour.

⬇ Download