Live updates: a connection that survives navigation
The feature
A live surface — a dashboard, a token stream, a build log, tool-call status —
needs a connection that outlives the page it started on. The
Client Router gives you the seam: open your
connection inside an island, mark it persist, and
June carries that live node — React state, open socket and all — across a soft
navigation instead of tearing it down and reconnecting.
// app/StatusFeed.tsx — an island that owns its own connection
import { useEffect, useState } from "react";
export function StatusFeed() {
const [events, setEvents] = useState<string[]>([]);
useEffect(() => {
const es = new EventSource("/api/status"); // server push (SSE)
es.onmessage = (e) => setEvents((prev) => [e.data, ...prev].slice(0, 50));
return () => es.close();
}, []);
return (
<ul>
{events.map((line, i) => <li key={i}>{line}</li>)}
</ul>
);
}
// app/dashboard/layout.tsx — persist it so it survives navigation
import { Island } from "@junejs/core/islands";
import { StatusFeed } from "../StatusFeed";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Island name="StatusFeed" component={StatusFeed} persist />
{children}
</>
);
}
Click between pages inside the dashboard and the feed keeps streaming — no flash,
no dropped connection, no re-subscribe. Without persist, every navigation would
restart the EventSource from scratch.
SSE or WebSocket — pick by direction
The island owns the transport, so use whichever fits:
- Server-Sent Events (
EventSource) for one-way server→client streams (live logs, notifications, AI token streams). It has built-in auto-reconnect and resumes withLast-Event-ID— the least code for the most common case. - WebSocket when the client also talks back (chat, collaborative editing, interactive control planes).
Either way the page stays a complete, projectable document — .md, .json, and
/mcp are untouched. Live updates live only on the human
surface; the agent surface still gets the same clean snapshot.
Deploying a connection: what to know
A held-open connection behaves differently from a request/response route, and the limits depend on where you deploy:
- A single connection is capped (~5 minutes by default on most hosts). This is normal — design for it. SSE's auto-reconnect handles it for free; for WebSocket, reconnect on close. Treat a drop as routine, not an error.
- An open connection bills for the time it's held, even while idle — it's
occupancy, not CPU (waiting is free). Push only when data actually changes, and
the connection stays cheap. On Cloudflare, long-lived connections belong in a
Durable Object (with WebSocket Hibernation, an idle connection costs nothing);
on Vercel, a function streams up to its
maxDurationthen reconnects. - Offer a fallback. If a connection can't be established (a strict proxy, an old client), poll the same endpoint on an interval. The UI code doesn't care how the next update arrived.
You only pay for the pages that want it
Live updates are inherently opt-in: a page with no connection island is still zero-JS and costs nothing extra. The streaming surfaces are exactly the ones you marked — everything else stays a plain, fast, cacheable document. Add a connection where the experience needs one, not site-wide.
Why it matters
Most frameworks make you choose between "instant document" and "live app" at the
project level. June lets you keep the document floor everywhere and add a surviving
connection precisely where a surface earns it — one island, one persist, your own
transport. The hard part (keeping the live node alive across navigation) is the
framework's job; the connection itself stays plain web-standard code you fully
control.
See it live
The Cake Site demo runs this
exact pattern on Vercel's edge: a 🔴 live feed in the header opens an EventSource
to /api/activity from inside a persist island. Click between the two recipes and
watch the event count keep climbing — the connection streams straight through the
navigation instead of reconnecting. Source:
junebuild/cake (StatusFeed.tsx,
_extra.tsx).