A live region is not a long request
A team-chat page on a runtime with buffered replies: what Suspense still means, why subscriptions need their own authentication and authorization checks, and how polling cost follows the whole log.

On this page
Take a small team-chat application: a room page with the history of what people have said, and new messages that appear while you read. In most web frameworks that page is a small job, because it can lean on three things the framework doesn’t have to own: a renderer that can stream, a WebSocket library and a pub/sub service to say that something has happened. An application on Ferrite can’t reach for any of them. It gets one dependency, the platform’s SDK, and no ambient access to other libraries or services. The web framework I’ve developed with agents for this environment is in the same position. Its compiler runs on the developer’s machine, and what ships is generated Rust calling a runtime package whose only dependency surface is that SDK, so the framework has no private door into the platform. This chat application is one of its reference applications, and it exists to exercise exactly this case. Walking through it, as the code stood in November 2026, shows which familiar parts come through intact and what had to be built again once the page stopped being one request.
The first response
The request for the room page reaches a pool of workers competing to consume from a queue, and the order of what happens next is deliberate. An undecodable envelope, or one with no reply channel, is poison and gets acknowledged rather than redelivered forever. A request whose deadline has already passed runs no application code at all, because its answer can reach nobody and doing the work would spend a worker slot and possibly money. Inputs are bounded and the path is canonicalized once, so middleware and the router can’t disagree about what it is. Application code then runs under whatever remains of the time budget, with the timeout inside the spawned task so that expiry drops the future rather than detaching it to carry on unobserved, and even an error page only gets what is left. The worker replies first and acknowledges only once the reply has been published, so a failure in that gap produces a redelivery rather than a lost request. Routing, layouts, escaping and cached loaders come through without much drama, because they are only code and need no help from the platform to run.
<Suspense> is the first thing to change shape. A route answers with one buffered reply, so on a full page load the server resolves every boundary inline, and a crawler or a reader without JavaScript gets complete HTML. On a navigation within the app the server sends the fallback, and the client fetches the deferred region in a second, stateless request that any instance can answer. Deferral survives as another request rather than as a stream, and on a first load the slowest loader on the page decides when any of it leaves. Streamed replies would have to be a platform capability, and a framework on the public SDK can’t add one from the inside.
The live part of the room page looks like this:
<Live source={&room.source} from={room.cursor}>
{#for message in &room.messages key={message.offset}}<li>{message.text}</li>{/for}
</Live>
The page’s loader reads the history and the log offset where that history ended together, so the markup stops exactly where the subscription will start, and nothing is replayed or missed between them. Because the history is in the HTML, someone with JavaScript off sees everything said before they arrived, and only the appending is live.
A different door
A live region can’t be the tail of the first response, so it starts afterwards, as a subscription that names a source and the log offset to read from. It arrives through an SSE stream face on the platform. The gateway publishes one lifecycle envelope per subscription onto a queue of its own, a GET to open it and a STREAM_CLOSE to end it, and the application answers with frames. Nothing in the framework opens a socket.
Because that envelope arrives on a different queue, it never passes through the request path, and everything the first response got from that path has to be established a second time. A page load picks up three things there with hardly anyone noticing: a check of who is asking, a decision about what they may have, and a deadline that stops it waiting forever. A subscription has to be given each of them on purpose.
The check of who is asking comes first. An application’s authentication usually lives in middleware, and middleware only protects the paths it runs on, so a subscription has to meet the same canonical path check, query bounds and middleware chain as a request before it reads anything. A middleware answer such as a 401 or a redirect can’t be carried on a stream, so it has to become a refusal that ends the subscription, which also tells the reader at once that nothing is coming.
The decision about what they may have is the easier one to get wrong, because the source name comes from the browser and looks like a routing detail. An application declares its sources by registering renderers, each turning one log record into the markup the region appends, and that registry is also the authority on what can be subscribed to. So comparing the name a browser sent with the names an application declared is an access decision, whatever it looks like, and it has to be as strict as any other: a name no declaration covers gets nothing, and the right to read one source says nothing about the next. The registry can say what exists, but only the application knows whether this reader may see it, and that question has to be asked of the subscription as well as of the page.
The deadline has no direct equivalent, because a subscription is supposed to stay open, so what it needs instead is a guarantee that something answers. The SSE transport has already opened by the time the application hears about a subscription, so the answer can’t be a 404, and a gateway heartbeat shows only that the connection is alive, so a subscription that nothing answered would hold a stream slot for as long as the client stayed. The runtime therefore starts its stream consumer when a stream binding exists or a renderer is registered, and an unknown source gets a terminal refusal that closes the stream, so a reader can tell a misconfigured region from one that is merely quiet. Frames and refusals also go to the gateway that admitted the subscription, because a reconnect may arrive through a different one. A regression test gives the refusal two seconds to arrive, and the chat application’s own test covers live delivery, reconnection and refusal through two gateways, all of it run locally.
Having no ambient services has one pleasant side effect, which is that you can say exactly what a feature needs. A live region needs a stream queue, a frame queue and an event log, and the refusal path needs only the two queues. An API-only application with neither renderers nor a stream binding starts without any of them, and a binding of the wrong kind fails at startup instead of being discovered by the first subscriber.
What a tailer costs
A valid subscription gets a tailer, a loop tied to that one connection that reads the application’s event log. This is where the design differs most from the usual one. The platform has no wake-on-append for a log reader, a recorded gap, so nothing can tell a tailer that there is something to read, and it has to go and look. In the November code it reads up to 64 records at a time and waits 200 milliseconds when a read comes back empty, so a new message can sit in the log for about that long before its tailer sees it.
That sets two costs. On a quiet log each subscription makes about five reads a second, so a thousand open tabs make about five thousand reads a second and learn nothing from them, a figure that is arithmetic from those two constants and not a load test. The second cost is easier to miss. An event is a log record keyed by its source, so one log can serve many rooms, but the filtering happens in the application after the read. The tailer for a silent room still reads every other room’s records to find out they aren’t for it, and a busy log keeps every tailer reading without a pause. With a pub/sub wake-up, work follows the events a subscriber cares about. Here it follows subscribers multiplied by everything written to the log. For a small chat application that may be a perfectly good price. It does mean that live isn’t free in the way a developer used to push might assume, and the source calls it the economics being bought rather than leaving anyone to discover it.
What the polling gets in return is a very simple idea of position. Log offsets are dense, consecutive with no holes, so a client that has entry 41 asks for 42 on its next connection, whichever gateway it reaches. There is no window to replay and deduplicate, and reconnecting isn’t a special mode, only another subscription with a later offset. A missing or malformed cursor reads as zero rather than as an error, on the reasoning that the worst outcome is a region that renders its whole history, and failing a subscription because a query parameter was ugly would be worse.
Stopping
A request has a deadline, but a subscription has no natural end, so its end has to be defined some other way. When a connection drops, gracefully or not, the gateway publishes the close envelope and that stops the tailer. Without it, every reader who closed a laptop would leave a loop polling the log forever. A close only helps in the worker that owns the tailer, though, and in Rust dropping a task’s JoinHandle detaches the task rather than stopping it. So a worker keeps its tailers in a set that aborts all of them when it goes out of scope, including while a panic unwinds. Without that, a dead worker would leave tailers running that nothing could stop, since a later close would reach the replacement worker’s empty table, and each one would be duplicated the moment its reader reconnected. A redelivered open replaces any tailer already running for that connection, for the same reason.
The message itself
Saying something in the room is an ordinary action, a form post that works without JavaScript and gets a 303. I’ve described elsewhere how the framework’s actions work: staged effects happen at most once, and a redelivery gets the recorded reply. A chat message doesn’t get that guarantee, because it is a log append, the log is a different plane from the action’s envelope, and atomicity doesn’t cross planes. A retry or a hedge replays the recorded reply without running the handler again, so only a genuine race between two deliveries on a cold key can post twice. The application says so in its README and in the comment on the action, and on this page that mistake would be unusually easy to see, because the message would appear twice in every open tab.
What survived
Some of what a developer arriving from Next.js would look for isn’t there at all, such as image and font optimization, with files under public/ served as they are, and internationalization. Streaming and wake-on-append are a different kind of absence, because they are recorded as gaps in the platform, which puts the fix in the right place and keeps the framework from faking them.
So the parts of a web framework that survived this runtime are the ones that are only code: HTML construction, routing, layouts, caching. The parts that depended on a capability, holding a response open or being woken when data arrives, took the shape the platform could offer and acquired a price that is visible in the design. The subscription contract, a declared source and an offset, doesn’t mention polling, so a wake-up on the log would make tailers cheaper without changing what the page asks for. The warning that goes with it is that a second way into a system inherits none of the first one’s protections until someone puts them there.
Chris Chabot, November 2026