Skip to content

Hot Reload for Local HTML Files

A tiny, dependency-free poller you embed in a self-contained HTML artifact so the human reviewing it in the browser never has to refresh by hand. It watches its own URL and reloads when the bytes change, but only when served from localhost, so a copy opened as file:// or hosted anywhere real carries the script inertly: zero network, zero behavior. The same file you hand a stakeholder is unaffected.

When to use

Use this when an agent iterates on a self-contained HTML artifact (a prototype, a design doc, a single-file app) and a human reviews the result in a browser. The poller closes the edit-refresh loop without a bundler, a watcher process, or a websocket. Because it keys off the hostname, the exact same file is safe to share: hand it to a stakeholder over email or host it on a real domain and the script does nothing.

Pairs naturally with the Single-File HTML App pattern and the companion static server below.

How it works

  • The script no-ops unless location.hostname is a localhost address.
  • Otherwise it fetches its own URL (cache: "no-store") on an interval, compares the response to the previous fetch, and calls location.reload() on any change.
  • It needs the artifact served over http://localhost, so use the companion server below rather than opening the file directly.

The hot-reload script

Paste before </body>. It is self-contained: no build, no dependencies. An inline <script type="module"> gets its own scope and strict mode, so the identifiers stay off window without an IIFE wrapper:

html
<script type="module">
  // Active only on localhost. The same file handed to a stakeholder,
  // opened as file:// or hosted anywhere real, carries this inertly.
  const host = location.hostname;
  const isLocal = ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"].includes(host);
  
  if (isLocal) {
    let last = null;
    
    poll();
    
    async function poll() {
      try {
        const response = await fetch(location.href, { cache: "no-store" });
        const text = await response.text();
        if (last !== null && text !== last) {
          location.reload();
          return;
        }
        last = text;
        setTimeout(poll, 1000);
      } catch {
        setTimeout(poll, 2000);
      }
    }
  }
</script>

Companion: a zero-dependency static server

Any static file server works; this is the one to reach for when you want no dependencies. It sends cache-control: no-store so the poller always sees fresh bytes. Run node serve.mjs and open the artifact through http://localhost:8011/...:

js
#!/usr/bin/env node
// Tiny zero-dependency static server for local preview and hot reload.
// The HTML files poll their own URL and reload on change, but the poll
// only runs over http from localhost, so open them through this server,
// not as file://. Usage: `node serve.mjs [port]` (default 8011).
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize } from "node:path";

const ROOT = process.cwd();
const PORT = Number(process.argv[2]) || 8011;
const TYPES = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".mjs": "text/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".svg": "image/svg+xml",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".gif": "image/gif",
  ".webp": "image/webp",
  ".md": "text/markdown; charset=utf-8",
  ".woff2": "font/woff2",
  ".ico": "image/x-icon",
};

createServer(async (req, res) => {
  try {
    let urlPath = decodeURIComponent(
      new URL(req.url, "http://x").pathname,
    );
    if (urlPath.endsWith("/")) urlPath += "index.html";
    let file = join(ROOT, normalize(urlPath));
    if (!file.startsWith(ROOT)) {
      res.writeHead(403).end("forbidden");
      return;
    }
    if ((await stat(file)).isDirectory()) file = join(file, "index.html");
    res.writeHead(200, {
      "content-type":
        TYPES[extname(file).toLowerCase()] || "application/octet-stream",
      "cache-control": "no-store",
    });
    res.end(await readFile(file));
  } catch {
    res.writeHead(404, { "content-type": "text/plain" }).end("not found");
  }
}).listen(PORT, "127.0.0.1", () => {
  console.log(`serving ${ROOT} at http://localhost:${PORT}/`);
});

Trade-offs

The hostname is the only thing checked. The script keys off location.hostname being a localhost address, with no build flag or environment variable. This is the point of the pattern, but it means an app that genuinely runs on localhost in production (an Electron shell, say) would keep polling. For browser artifacts handed to people that is exactly the desired behavior.

Full-page reload, no state preservation. Each change triggers location.reload(), so any in-page state is lost. That is fine for reviewing a static artifact but is not a substitute for hot module replacement in a real app.

Polling, not push. The poller refetches on a one-second interval rather than holding a websocket open. The cost is a steady trickle of small same-origin requests while a localhost tab is open, which is negligible for local review and keeps the artifact dependency-free.