Live pages (SSE)
A page that shows what other people are doing goes stale while it is open. With server-sent events (SSE) the page keeps one connection to the server, and the server says on it when something changed. It is plain HTTP: no other port, no other protocol, and the browser connects again by itself when the connection drops.
The code on this page is from Bookings, the example app in examples/bookings (source). Its day view shows every room's bookings; when someone books, confirms or cancels, everyone looking at that day sees it without reloading.
A stream is a route
/// Mounted by `server.mountFeatures(features)` in src/main.zig. `.public`:
/// the home page stays open once the app has a login (spider g auth).
pub fn build() spider.Group {
var g = spider.Group.init("");
_ = g
.get("/", controller.index, .{ .public = true })
// The stream that tells a day view its day changed.
.sseWith("/events/:day", controller.events, .{ .public = true, .quiet_log = true });
return g;
}
sseWith takes the same config as any route: this stream is .public. With .authenticated, .roles or .policy, the check runs before the stream opens, and a visitor who does not pass gets the same answer as on any other route. .quiet_log keeps a line per open stream out of the log.
The handler gets a *spider.Sse instead of a *spider.Ctx:
/// The stream a day view listens to: one event, "changed", whenever a
/// booking of that day is made, confirmed, cancelled or expires. It says
/// that something changed, not what: each page then asks for its own grid,
/// because the grid is not the same for everyone (yours are marked as yours).
///
/// /events/2026-10-12
pub fn events(sse: *spider.Sse) !void {
const day = sse.param("day") orelse return;
if (!isDay(day)) return;
try sse.join(try channelOf(sse.arena, day));
sse.wait();
}
fn channelOf(a: std.mem.Allocator, day: []const u8) ![]const u8 {
return std.fmt.allocPrint(a, "day:{s}", .{day});
}
sse.param("day")is the:dayof the path.sse.header(name)andsse.cookie(name)read the request.sse.join(channel)says what this connection listens to. A channel is a name the app makes up: here one per day,day:2026-10-12.sse.wait()holds the stream open until the browser goes away. When the handler returns, the stream ends: a handler that returns early, as this one does for a path that is not a date, just closes it.
The browser sees the stream open when the handler listens (join, wait) and not before, so nothing said on the channel after that moment is missed.
Saying that something changed
Whoever changes a booking tells the channel of its day. From a handler, the hub is c.sseHub():
/// Tells every page showing `day` that it changed.
pub fn announce(c: *spider.Ctx, day: []const u8) void {
const channel = channelOf(c.arena, day) catch return;
c.sseHub().emitTo(channel, "changed", .{ .day = day });
}
emitTo(channel, event, data) sends the event to every connection that joined the channel, with data as JSON. The handlers that make, confirm or cancel a booking call it once the database has done its part:
/// Gives a booking up.
pub fn cancel(c: *spider.Ctx, mine_: Mine) !Response {
const booking = mine_.value;
_ = try repository.cancel(c.arena, booking.id);
day_view.announce(c, booking.day);
return c.redirect(try std.fmt.allocPrint(c.arena, "/bookings/{d}", .{booking.id}));
}
A job has no request, so it is given the hub. This one releases the holds nobody confirmed and tells the days they were on (see Scheduled jobs):
/// Releases the holds that ran out and tells the pages showing their days.
/// It runs on a thread of its own, outside any request, so it brings its
/// own memory and reports its own errors: there is nobody to return one to.
pub fn expireHolds(hub: *spider.Hub) void {
var arena_state: std.heap.ArenaAllocator = .init(std.heap.smp_allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const days = repository.expireStale(arena) catch |err| {
std.log.warn("bookings: could not release the holds that ran out: {s}", .{@errorName(err)});
return;
};
for (days) |day| {
const channel = std.fmt.allocPrint(arena, "day:{s}", .{day}) catch continue;
hub.emitTo(channel, "changed", .{ .day = day });
}
if (days.len > 0) std.log.info("bookings: {d} hold(s) ran out and were released", .{days.len});
}
This is what a browser on /events/2033-03-07 receives when a booking of that day is made:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
retry: 3000
: heartbeat
id: 6
event: changed
data: {"day":"2033-03-07"}
retry tells the browser to wait three seconds before connecting again after a drop. The : heartbeat line is a comment, which browsers skip: see the end of this page.
The page, with htmx 4
Bookings uses htmx 4 and its SSE extension (spider new --htmx=4 sets a project up with both). Two elements of the day view do the work:
<div hx-sse:connect="/events/{ day.day }" hx-swap="none"></div>
<div id="day" hx-get="{ self }" hx-trigger="changed from:body, every 60s" hx-select="#day" hx-swap="outerHTML">
...
</div>
The first opens the stream of the day on screen. An event with a name becomes an event in the page, so changed from the server is a changed the second element hears (from:body): it asks for the same address again and replaces itself with the new #day. every 60s is the fallback, and what moves the "now" line.
The event carries no HTML on purpose. Each visitor asks for the page as themselves, so what they get went through the same route, the same access rules and their own session: a booking shows "yours" only to its owner. The alternative is to send the HTML itself, c.sseHub().emitHtmlTo(channel, "", html): a message without a name is what hx-sse:connect swaps into its element. That fits when everyone sees the same thing.
The page, without htmx
The browser's own EventSource is enough:
<script>
const stream = new EventSource("/events/2033-03-07");
stream.addEventListener("changed", (event) => {
const data = JSON.parse(event.data);
console.log("changed:", data.day);
});
</script>
A test for a stream
The test opens the stream the way a browser does, makes two bookings and reads what arrives: the booking of another day says nothing, the one of this day says changed.
test "the day's stream says \"changed\" when a booking of that day is made, and only then" {
const app = try app_test.running();
const io = std.testing.io;
var id_buf: [20]u8 = undefined;
const cedar = try roomId(&id_buf, "Cedar");
var buf: [1024]u8 = undefined;
const ana = app.with(&.{try app_test.signUp(app, &buf, "Ana Ribeiro", "ana.stream3@example.com")});
// What a day view does: open the stream of its day and keep it open.
const address = try std.Io.net.IpAddress.parse("127.0.0.1", app.port);
var stream = try address.connect(io, .{ .mode = .stream });
defer stream.close(io);
const timeout = std.posix.timeval{ .sec = 3, .usec = 0 };
try std.posix.setsockopt(stream.socket.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&timeout));
var write_buf: [256]u8 = undefined;
var writer = stream.writer(io, &write_buf);
try writer.interface.writeAll("GET /events/2032-07-05 HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: text/event-stream\r\n\r\n");
try writer.interface.flush();
var read_buf: [2048]u8 = undefined;
var reader = stream.reader(io, &read_buf);
var seen: std.ArrayList(u8) = .empty;
defer seen.deinit(std.testing.allocator);
try readUntil(&reader.interface, &seen, "text/event-stream");
try readUntil(&reader.interface, &seen, "retry:");
// The stream opens for the client once its handler listens, so what is
// made from here on is not missed.
// A booking of another day says nothing here; one of this day does.
var other = try ana.postForm("/bookings/create", Booking{ .room = cedar, .date = "2032-07-06", .start = "10:00", .end = "11:00", .title = "Another day" });
defer other.deinit();
try other.expectStatus(303);
var made = try ana.postForm("/bookings/create", Booking{ .room = cedar, .date = "2032-07-05", .start = "10:00", .end = "11:00", .title = "Sprint demo" });
defer made.deinit();
try made.expectStatus(303);
try readUntil(&reader.interface, &seen, "event: changed");
try readUntil(&reader.interface, &seen, "2032-07-05");
try std.testing.expect(std.mem.indexOf(u8, seen.items, "2032-07-06") == null);
// And the page that asks again finds it there, pointed out as new.
var day = try app.get("/?date=2032-07-05");
defer day.deinit();
try day.expectContains("ui-cell-changed");
try day.expectContains("Just booked");
try day.expectContains("hx-sse:connect=\"/events/2032-07-05\"");
try day.expectContains("hx-trigger=\"changed from:body, every 60s\"");
}
More than one channel, and what was missed
sse.subscribe(channel)adds a channel and keeps the ones the connection has;joinreplaces them. A browser opens few connections to one address, so a page that follows several things should use one stream with several channels.sse.joinUser(id)joins the channel of one account, andhub.notifyUser(id, event, data)sends to it.sse.joinWithReplay(channel): a browser that connects again says the id of the last event it saw, and this sends what the channel recorded after it (the last 50 events, at most five minutes old).sse.send(event, data)sends to this one connection, beforewait(): its first state, for example.
In production
A proxy between the browser and the app may close a connection that says nothing for a while. sseHeartbeat sends a comment line on every stream at an interval, which keeps them open. Bookings turns it on where it makes its server, in main: .sseHeartbeat(null) is every 30 seconds, and a number is that many milliseconds.
A proxy that buffers responses holds events back: turn buffering off for the stream's path. And a connection that cannot take what is sent to it for stream_write_timeout_ms (10 seconds) is dropped, so one slow reader does not hold the others.