Renoxium Docs Pricing Sign in Sign up

09 — Partial rendering and realtime

For partial page updates the server sends HTML fragments and the browser swaps only what changed. One template and one loader serve both full renders and fragments; no extra endpoints are written.

Fragment flow

  1. The first request to /products renders the full page.
  2. htmx sends GET /products?q=shoes with HX-Request: true and HX-Target: product-list.
  3. Rust runs the same loader (told which fragment is requested) and renders only the product_list block via minijinja's per-block render. The layout is not rendered.
  4. The response is a few KB of HTML. htmx swaps the fragment with the morph extension (idiomorph), wrapped in a View Transition.

Actions that update several parts at once (a button and the cart badge) return { fragments: [...] }; Rust renders the extra fragments and marks them with hx-swap-oob="true" automatically.

Why it feels smooth: morphing changes only differing nodes, so input focus, scroll position, playing media, and running CSS animations are preserved; the View Transitions API animates state changes between fragments. Elements with view-transition-name slide to their new positions.

Interaction layers

NeedSolutionDone by
First pageFull SSRRust + loader
Filter, paging, formsFragment + morphRust renders, htmx swaps
Dropdowns, modals, tabsAlpine.js or vanilla JSBrowser, no server round trip
Highly interactive widgetsIslands (Preact/Svelte/Solid), bundled at deploy by the CLIBrowser
Notifications, feeds, progressSSERust
Chat, collaborationWebSocketRust

Why htmx rather than a custom client: htmx and idiomorph are mature and documented; the platform only needs to understand their headers on the server. A custom client is justified only later for tighter integration (automatic form validation from loader schemas, prefetching).

Realtime: SSE and WebSocket

Connections are owned by Rust in the tenant's cell, not by the isolate. The isolate is invoked only on connect, incoming message, and disconnect, so thousands of idle connections use no tenant heap. Cost per connection is a few KB in tokio.

SSEWebSocket
DirectionServer → browserBoth
Good forNotifications, feeds, progress, live fragment updatesChat, collaboration, games, high-frequency input
htmx integrationhx-ext="sse", sse-connect, sse-swaphx-ext="ws", ws-connect, ws-send
Through proxies/CDNsEasy (plain HTTP)Needs upgrade support
// functions/ws/chat/[room].ts  ->  wss://host/ws/chat/:room
export const ws = {
  async open({ conn, params, auth, realtime }) {
    await auth.requireUser(conn.req);
    await realtime.subscribe(conn, `room:${params.room}`);      // checked by realtime.authorize
  },
  async message({ conn, data, params, realtime, render }) {
    const html = await render("partials/chat-message.html", { msg: data, user: conn.user });
    realtime.publish(`room:${params.room}`, html);              // HTML fragment to all subscribers
  },
  close({ conn }) { /* optional */ },
};
<div hx-ext="sse" sse-connect="/sse/orders" sse-swap="order-updated">…</div>
  • realtime.publish(topic, payload) can be called from WebSocket handlers, actions, services, or durable event handlers. Because all of a tenant's requests are on one node, pub/sub is in-process (tokio::sync::broadcast). Cross-node delivery exists only for replica-tier tenants, through a pluggable transport (NATS/Redis).
  • Realtime from data changes uses SQLite's update_hook: changed table and rowid are mapped to topics and filtered by policy.
  • Security and reliability (#118): an upgrade is refused with 403 before the hub or the isolate is touched when its Origin is not the app's own host (any scheme, same port) or one of [security.cors] origins exactly (* allows nothing here), when it is malformed or null, or when a WebSocket upgrade carries a cookie but no Origin (browsers always send one; a client with no cookie may omit it, and SSE may, since browsers omit it on a same-origin GET). renox dev rewrites its own origin to the app's. The principal is resolved from the session cookie once at connect and is ctx.principal in every handler. A topic is a Cedar resource: subscribe without a permitting policy is refused (fail closed), and a refusal in open closes the socket with 1008. Heartbeats; connection limits per tenant and per user; message size limits; backpressure (slow clients are disconnected, and one tenant's flood does not delay another's); SSE supports Last-Event-ID for short replay after reconnect. Tests: crates/renox-realtime/tests/security.rs, crates/renox-cell/tests/realtime.rs.
  • Realtime is metered per tenant (connection-seconds and messages) for billing.

Contract. The runtime side of this section is docs/contracts/ops.md section 18 (1.5.0, #272): route kinds ws (functions/ws/**, export const ws = { open, message, close }) and sse (functions/sse/**, export const sse = { open, close }), the conn handle, ctx.realtime (subscribe, unsubscribe, publish, send, close), payloads as text, JSON, or a partial rendered by the cell ({ fragment, data }, which is how the render(...) of the example above reaches subscribers), the change feed from [realtime.feeds] after commit, the Origin rule at the upgrade, the limits table (connections per tenant and per user, 64 KB messages, a 256-message queue with a 1008 close for slow consumers, heartbeats, Last-Event-ID replay), and one meter record per connection. The realtime.authorize hook named above is a Cedar policy, not a hook: subscribing is the action realtime.subscribe on the resource Renox::Topic (attributes name and segments), fail-closed when no policy permits it.

Source on GitHub