A page that goes stale

An order page shows "Preparing". In the kitchen, someone marks the order ready. The customer's page still says "Preparing" until they refresh.

There are two common fixes. The page can poll, asking the server every few seconds whether anything changed. Or the component can subscribe to a channel and call a reload from the handler, which works, and which every page then rebuilds by hand: the channel name is written a second time in the component, two events close together cause two reloads, and nothing catches up after the connection drops.

What Inertia 3.11 added

Inertia 3.11 added live props. The server names, for each prop, the channels and events that mean "this changed". The browser subscribes, and when one of those events arrives, Inertia fetches that one prop again with a partial reload. Throttling, hidden tabs and reconnects are handled once, below the page component.

That release is the reason for this post. I maintain Guren, a Laravel-shaped fullstack TypeScript framework that runs on Bun and Hono and renders pages with Inertia and React, and when live props came out I wanted Guren apps to have them too. What Inertia needs from a backend is the declaration in the page it renders, and a transport that delivers the events. Since 2.36 Guren has both: a live() helper for controllers, and a transport that uses the WebSocket endpoint already built into the framework. Laravel apps get the same feature through the official adapter. This post is about what it looks like on a TypeScript backend.

The demo

A small diner app with two pages. The kitchen board lists orders and moves each one through its steps. The tracking page shows one customer their order.

// Detect dark theme var iframe = document.getElementById('tweet-2120859070328651935-801'); if (document.body.className.includes('dark-theme')) { iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2120859070328651935&theme=dark" }

The left window is the kitchen, the right one is the customer. Nobody touches the right window. In the recording its page changed 0.12 to 0.17 seconds after each click, with both browsers on one machine.

The code is at github.com/gurenjs/live-props-demo.

Building it

Start from a new app and add broadcasting:

bunx create-guren-app guren-diner --db sqlite cd guren-diner bunx guren add broadcasting 

The second command registers a broadcast manager with an in-memory driver, which is enough for one server process.

Live props subscribe over a socket, so the app needs a socket route:

// routes/web.ts import { type BroadcastManager, getRequestContainer, Router } from '@guren/core' export function registerWebRoutes(router: Router): void { router.get('/broadcasting/socket', (c, next) => getRequestContainer(c).make<BroadcastManager>('broadcast').webSocketMiddleware()(c, next), ) } 

The browser needs the transport. In resources/js/app.tsx, the one added option is live:

// resources/js/app.tsx void Promise.all([import('@guren/inertia-client'), import('@guren/inertia-client/live')]).then( ([{ startInertiaClient }, { liveTransport }]) => startInertiaClient({ pages, pageManifest, live: liveTransport(), }), ) 

Next, an event that says which order changed and which channel carries the news:

// app/Events/OrderUpdated.ts import { Event } from '@guren/core' export class OrderUpdated extends Event { static override eventName = 'OrderUpdated' constructor(readonly orderId: number) { super() } broadcastOn(): string[] { return [`orders.${this.orderId}`] } broadcastWith(): Record<string, unknown> { return { orderId: this.orderId } } } 

The payload is the id and nothing else. The event tells the browser to ask again. It does not carry the order.

Now the controller. This is the line the feature is named after:

// app/Http/Controllers/OrderController.ts async show(): Promise<Response> { const { id } = this.validateParams(OrderIdParams) const order = await Order.findOrFail(id) return this.inertia( pages.orders.Show, { order: live(order, { on: new OrderUpdated(order.id) }) }, { title: `Order #${order.id} | Guren Diner` }, ) } 

live() takes an event instance, so it can read the channel for this particular order off it. The same instance shape is what gets broadcast when the kitchen changes something:

async #setStatus(id: number, status: OrderStatus): Promise<void> { await Order.update({ id }, {}, { set: { status, updatedAt: new Date().toISOString() } }) const broadcast = this.make<BroadcastManager>('broadcast') await broadcast.broadcastEvent(new OrderUpdated(id)) await broadcast.broadcastEvent(new OrdersChanged()) } 

OrdersChanged is a second event on a shared orders channel. The kitchen board's list is a live prop too, so two kitchen screens stay in step with each other.

The React page did not change. It declares order in its Props and renders it, the same as before the prop was live.

What goes over the wire

The first response for /orders/2 carries the prop and, next to it, what to listen for:

{ "component": "orders/Show", "props": { "order": { "id": 2, "customer": "Ben", "item": "Katsu curry", "status": "received" } }, "liveProps": { "order": { "listeners": [{ "channel": { "name": "orders.2", "type": "public" }, "events": ["OrderUpdated"] }] } } } 

The browser subscribes to orders.2 on the socket. When OrderUpdated arrives, Inertia sends an ordinary partial reload for order. That request goes through the route's middleware and the controller like any other, which has a useful consequence: a live prop can never show a user more than the page itself would.

A few defaults are worth knowing. Each prop reloads at most once a second, so a burst of events becomes one request. A hidden tab makes no requests and catches up when it becomes visible. After a dropped connection, the transport reconnects and Inertia reloads every live prop, because events sent in the gap never arrived.

Things to get right

Pin the event's name. Without static eventName, the name is the class name, and a production bundler can rename a class when two modules declare the same one. The prop then stops refreshing and nothing reports an error. bunx guren check warns about an unpinned name.

Use a private channel for anything a stranger should not hear about. The demo has no login, so its channels are public: anyone can subscribe to orders.2 and learn that order 2 changed, though not what it changed to. With users, register an authorizer for the channel and it decides who may subscribe.

Skip the sender. The person who clicked already has the new state from their own response. Passing the request as except keeps their tab from reloading a second time:

await broadcast.broadcastEvent(new OrderUpdated(order.id), { except: this.ctx }) 

When this is the wrong tool

  • Data that changes many times a second, such as cursor positions or typing indicators. A reload per event is too heavy. Subscribe to the channel directly and use the payload.
  • A host with no long-lived socket. On Cloudflare Workers, AWS Lambda and Vercel the socket route answers 501. The page still renders and works with the props it was given, but it does not update. Live props need a Bun server process.
  • More than one server process with the in-memory driver. An event broadcast on one process does not reach browsers connected to another. Use the Redis driver there.

Try it

bunx create-guren-app my-app cd my-app bun run dev 

The full guide, including deferred live props and private channels, is at Live Props. Guren itself is at github.com/gurenjs/guren.