Live Reload
bext supports PHP-like live reload: change a source file and the browser updates automatically. No restart, no manual refresh. The server watches for file changes, triggers an optimized rebuild, atomically swaps the V8 pool, and notifies connected browsers to soft-refresh.
How it works
Developer saves a file
│
▼
File watcher detects mtime change (1s poll)
│
▼
Debounce (200ms) — wait for batch saves
│
▼
Fast or full rebuild (see below)
│
▼
Create new V8 pool from fresh bundle
│
▼
Atomic swap (RwLock) — in-flight requests finish on old pool
│
▼
Clear ISR + component + compression caches
│
▼
Notify browsers (SSE event or ETag change)
│
▼
Browser soft-refreshes — swaps content + styles, no full reload
Configuration
# bext.config.toml
[build]
watch_dirs = ["server"]
live_reload = true # enable in production (always on in dev mode)
debounce_ms = 200 # wait for batch saves (default: 200ms)
| Key | Type | Default | Description |
|---|---|---|---|
watch_dirs |
string[] |
[] |
Directories to watch for changes |
live_reload |
bool |
false |
Enable automatic reload in production |
debounce_ms |
u64 |
200 |
Debounce window for batch saves |
script |
string |
(none) | Build script to run (full rebuild only) |
bun_path |
string |
"bun" |
Path to the Bun binary |
timeout_secs |
u64 |
60 |
Maximum build time before timeout |
Two rebuild modes
The watcher automatically picks the best mode based on what changed:
| Changed files | Mode | Speed | What runs |
|---|---|---|---|
.ts, .tsx, .js, .jsx |
Fast | ~50ms | Direct bun build entry.ts — skips build script |
.css, .json, .toml |
Full | ~200ms | Runs your [build] script (Tailwind, etc.) |
The fast path invokes Bun's bundler directly with the entry file, bypassing the external build script. This means editing a page or route handler reloads in under 100ms total.
Client-side refresh
When the server rebuilds, browsers need to know. The framework provides three notification modes:
Auto-detect (recommended)
// In your template's <body>:
{clientRuntime({ liveReload: true })}
With liveReload: true, the client tries SSE first (instant, 0ms latency). If the server doesn't have the 2s latency). No configuration needed — it just works.realtime feature enabled, it falls back to ETag polling (
SSE mode (instant, requires realtime feature)
{clientRuntime({ liveReload: "sse" })}
When the server swaps the pool, it pushes a "reload" event via Server-Sent Events. The browser receives it instantly and soft-refreshes. Requires [realtime] enabled = true in your bext config.
Polling mode (works everywhere)
{clientRuntime({ liveReload: "poll", pollInterval: 2000 })}
The browser sends a HEAD request every 2 seconds and checks the ETag header. When the pool swaps, all caches are cleared, so the next request has a different ETag. The client detects the change and soft-refreshes.
No live reload (just client navigation)
{clientRuntime({ navigation: true, liveReload: false })}
Client-side navigation still works (Turbo-like link interception, pushState, prefetch on hover) but no auto-refresh on server changes.
What "soft refresh" does
When a rebuild is detected, the client doesn't do a full page reload. Instead:
1. Re-fetches the current page's HTML via fetch()
2. Updates <link rel="stylesheet"> tags — new cache-busted URLs are added, stale ones removed
3. Updates <style> tags — inline styles are replaced with new content
4. Swaps <main> content — the page body updates without touching <nav>, <header>, <footer>
5. Uses View Transitions API if available — smooth crossfade animation
This means:
- Navigation state is preserved (scroll position resets to top)
- External stylesheets with cache-busted URLs always pull the latest CSS
- Inline styles (common in template sites) are always fresh
- No flash of unstyled content
Client-side navigation
The client runtime also provides SPA-like navigation (enabled by default):
{clientRuntime({ navigation: true })}
Features:
- Link interception — same-origin <a> clicks fetch HTML and swap <main>, no full page load
- history.pushState — URL updates without reload, back/forward works
- Prefetch on hover — after 65ms hover, pre-fetches the target page
- View Transitions API — smooth crossfade when available
- Fallback — external links, downloads, modifier keys (Ctrl+click) still work normally
Combined with live reload:
{clientRuntime({ navigation: true, liveReload: true })}
This gives you instant page transitions AND automatic updates when you edit source files.
Cache-busted assets
For Tailwind or other external CSS, use content-hashed filenames:
// Build script
await buildCSS({
sources: ["server"],
outFile: "dist/styles.[hash].css", // → styles.18vjj5s.css
manifest: "dist/css-manifest.json", // → { "styles.*.css": "styles.18vjj5s.css" }
});
// Template
loadManifest("dist/css-manifest.json");
function RootLayout({ children }) {
return (
<html>
<head>
{stylesheet(asset("/styles.css"))} {/* → /styles.18vjj5s.css */}
</head>
<body>
<main>{children}</main>
{clientRuntime({ liveReload: true })}
</body>
</html>
);
}
When the CSS changes, the build produces a new hash, the manifest updates, and the template references the new URL. The client's soft-refresh detects the changed <link> tag and loads the new stylesheet.
Three trigger methods
1. Automatic (file watcher)
Enabled by watch_dirs + live_reload = true. Watches .ts, .tsx, .js, .jsx, .css, .json, .toml files. Ignores node_modules, .git, dist.
2. API endpoint
# Full rebuild (runs build script)
curl -X POST http://localhost:3000/__bext/admin/api/bundle/reload
# Fast rebuild (TS only, skips build script)
curl -X POST http://localhost:3000/__bext/admin/api/bundle/fast-reload
Response:
{
"ok": true,
"mode": "fast",
"bundle_kb": 44,
"build_ms": 48,
"load_ms": 12,
"total_ms": 60
}
3. SIGHUP signal
kill -HUP $(pidof bext-server)
Useful for deploy scripts and systemd integration.
Production safety
Live reload is safe for production:
- Zero dropped requests. RwLock allows concurrent reads (every request) with rare writes (pool swap). The write lock blocks new requests for microseconds.
- Failed builds are harmless. If the build fails, the old pool and caches remain untouched. An error is logged, the server keeps running.
- Bounded memory. The old pool is dropped after in-flight requests complete. No leak from repeated reloads.
- Correct caches. ISR, component, and compression caches are cleared atomically after the swap. No stale content.
Build times
| Phase | Duration |
|---|---|
| File change detection | ~1s (polling interval) |
| Debounce window | 200ms (configurable) |
| Fast rebuild (TS only) | 30-80ms |
| Full rebuild (with Tailwind) | 100-300ms |
| V8 pool creation (4 workers) | 5-15ms |
| Atomic pool swap | < 1ms |
| Client notification (SSE) | < 1ms |
| Client notification (poll) | ~2s |
| Total (fast + SSE) | ~250ms |
| Total (full + poll) | ~3.5s |
Example: docs site with instant preview
# bext.config.toml
[server]
port = 3021
[build]
watch_dirs = ["server", "content"]
live_reload = true
debounce_ms = 300
// server/entry.ts
const router = defineRoutes({
layout: ({ children }) => (
<html>
<head>
{stylesheet("/styles.css")}
</head>
<body>
<nav><a href="/">Home</a> | <a href="/docs">Docs</a></nav>
<main>{children}</main>
{clientRuntime({ navigation: true, liveReload: true })}
</body>
</html>
),
routes: {
"/": { page: () => <h1>Welcome</h1> },
"/docs/[...path]": {
page: (ctx) => {
const content = pages.getPage("/docs/" + ctx.params.path);
return content
? <article dangerouslySetInnerHTML={{ __html: content.html }} />
: <p>Doc not found.</p>;
},
},
},
});
createSite({ hostname: "docs.example.com", router });
Edit any file in server/ or content/ → the browser updates in under a second, with smooth transitions and no full page reload.