Skip to content

Live Reload

Live reload watches your project files and refreshes the browser when changes are detected. CSS changes are hot-swapped without a full page reload, while other file types trigger a full refresh. It uses a WebSocket connection between the server and browser — no browser extension required.

Live reload consists of three parts that work together:

  1. liveReload middleware — injects a small client script into HTML responses.
  2. liveReloadWs WebSocket handler — maintains the WebSocket connection at /__pidgn/live-reload.
  3. swatcher file watcher — monitors directories for changes and broadcasts reload messages via PubSub.
  1. Add the middleware and WebSocket route

    const App = pidgn.Router.define(.{
    .middleware = &.{
    pidgn.errorHandler(.{}),
    pidgn.logger,
    pidgn.gzipCompress(.{}),
    pidgn.liveReload(.{}), // AFTER gzipCompress
    pidgn.staticFiles(.{ .dir = "public", .prefix = "/static" }),
    },
    .routes = &.{
    pidgn.Router.get("/", index),
    pidgn.Router.websocket("/__pidgn/live-reload", .{
    .handler = pidgn.liveReloadWs(),
    }),
    },
    });
  2. Start the file watcher in your main function

    const live_reload = @import("live_reload");
    pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    try live_reload.startWatcher(gpa.allocator());
    var server = pidgn.Server.init(gpa.allocator(), .{
    .port = 4000,
    }, &App.handler);
    try server.listen(std.io.defaultIo());
    }
  3. Configure swatcher in build.zig

    Add swatcher as a dependency and pass a build option to enable live reload conditionally:

    const live_reload = b.option(bool, "live_reload", "Enable live reload") orelse
    std.mem.eql(u8, env, "dev");
    if (live_reload) {
    const swatcher_dep = b.dependency("swatcher", .{ .target = target, .optimize = optimize });
    exe.root_module.addImport("swatcher", swatcher_dep.module("swatcher"));
    }

Place liveReload after gzipCompress in the middleware pipeline. The middleware injects a <script> tag before the closing </body> of HTML responses, so it needs to see the uncompressed response body. If placed before gzip, the body would be compressed and the injection point would not be found.

  1. The liveReload middleware intercepts HTML responses and injects a client-side script before </body>.
  2. The script opens a WebSocket connection to /__pidgn/live-reload.
  3. The liveReloadWs handler subscribes each connection to the "__live_reload" PubSub topic.
  4. The swatcher file watcher monitors configured directories (e.g. public/) for changes.
  5. When a file changes, the watcher broadcasts a message to the PubSub topic.
MessageTriggerBrowser behavior
{"type":"css"}A .css file changedReloads all <link> stylesheets by appending a ?_lr=<timestamp> query parameter. No full page reload.
{"type":"reload"}Any other file changedFull page reload via location.reload().

The client script automatically reconnects with exponential backoff (100ms to 5000ms). If the connection was lost for more than 1 second before reconnecting, a full page reload is triggered — this handles the case where the server restarted and files may have changed while disconnected.

OptionTypeDefaultDescription
endpoint[]const u8"/__pidgn/live-reload"WebSocket endpoint path for the reload connection.

The swatcher watcher is configured in your live reload module:

pub fn startWatcher(allocator: std.mem.Allocator) !void {
var watcher = try swatcher.Watcher.init(allocator, .{
.poll_interval_ms = 50,
.coalesce_ms = 200,
.dirs = &.{"public/"},
.watch_mode = .WATCH_DIRECTORIES,
});
// On change, broadcast to PubSub topic "__live_reload"
}
OptionValueDescription
poll_interval_ms50How often to poll for file changes (milliseconds).
coalesce_ms200Debounce window — changes within this period are batched into a single reload.
watch_modeWATCH_DIRECTORIESWatch directory-level changes only, avoiding duplicate events from individual file watches.

Use a comptime build flag to strip live reload from production builds:

const build_options = @import("build_options");
const App = pidgn.Router.define(.{
.middleware = &.{
pidgn.errorHandler(.{}),
pidgn.logger,
if (comptime build_options.live_reload) pidgn.liveReload(.{}) else null,
pidgn.staticFiles(.{ .dir = "public", .prefix = "/static" }),
},
.routes = &.{
pidgn.Router.get("/", index),
if (comptime build_options.live_reload)
pidgn.Router.websocket("/__pidgn/live-reload", .{
.handler = pidgn.liveReloadWs(),
})
else
null,
},
});

This ensures no live reload code is included in production binaries.