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 weresave file → watcher detects → pipeline resolves → build runs → ... → alt-tab → F5 → scroll back to where you wereThat 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 │
└───────────┘ └──────────────┘ ┌──────────┐ WebSocket ┌──────────────┐
│ Watcher │ ──── :4002 ────▶ │ Browser │
│ Machine │ │ Client │
└──────────┘ └──────────────┘
│ ▲
│ callbacks │ HTTP :4000
▼ │
┌───────────┐ inject on-the-fly ┌──────────────┐
│ Hot-Reload│ ◀──────────────────── │ Static │
│ Machine │ │ Serve │
└───────────┘ └──────────────┘- Hot-Reload Machine — WebSocket server lifecycle, client tracking, broadcast
- Static Server — serves
public/with on-the-fly<script>injection into HTML - 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';
}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;
}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 ...' }{ 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);
}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;
}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 ...', ...);
}
}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();
}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();
}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 });
},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 │
└──────────────────────────────────────────────────────┘ ┌─ 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');
}'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 };
}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 → repeatsave → alt-tab → F5 → scroll → repeatAfter:
save → press Enter → see itsave → press Enter → see itOr in auto mode:
save → see itsave → see itThe 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--devflag. No cleanup step.npm run build:staticproduces exactly what goes to production.- No second
public-dev/directory. No mode confusion.
What I'd Do Differently
Start with the static server. The
npx serve→--devflag → 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.The heartbeat
WeakSetpattern should be the default. Adding a listener per interval per client is a classic Node.js mistake. TheWeakSetapproach — mark alive on pong, check on next ping — is simpler and leak-free.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.