The UmimaClean Stack, Part 6: Realtime with Transmit and Server-Sent Events

Why SSE beat WebSockets for a one-directional problem - how Transmit channels and authorisation work, the two channels this app has, why broadcasts carry stored values instead of labels, and what breaks when you run more than one process.

A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing

Two screens in this app need to change without anyone touching them.

A customer sits on the payment page with a QRIS code open. They pay in their banking app. The page has to notice — because the alternative is a customer refreshing repeatedly, wondering whether their money went somewhere.

An admin has the dashboard open. Orders arrive, move, and get paid for. A dashboard that is fifteen minutes stale is a worse version of a report.

That is the entire realtime requirement, and its shape decided the technology.

The Shape Decides the Protocol

Both cases are server tells client. Nothing goes the other way. The client never publishes, never acknowledges, never negotiates.

WebSockets are a bidirectional protocol. Using them for a one-directional problem means paying for a capability you do not use:

WebSocketServer-Sent Events
DirectionBidirectionalServer → client only
Protocolws:// upgrade from HTTPPlain HTTP response
ReconnectionYou implement itBuilt into EventSource
Proxies / CDNsNeeds configurationJust an HTTP response
AuthCustom handshakeOrdinary cookies
Message formatBinary or text framesUTF-8 text

The reconnection row is the one people underestimate. A staff member’s phone drops from wifi to mobile data mid-shift; a customer’s screen sleeps while their bank app is open. With EventSource the browser reconnects on its own, with backoff, and you write nothing. With a raw WebSocket you write that, and you write it slightly wrong the first time.

The auth row is the other one. SSE is an HTTP request, so it carries the session cookie automatically — the same cookie every other request uses, checked by the same middleware. A WebSocket handshake needs its own answer to “who is this?”, and that answer is usually a token you now have to issue and expire.

@adonisjs/transmit is Adonis’s SSE layer. The client half is @adonisjs/transmit-client.

Configuration Is Two Lines

import { defineConfig } from '@adonisjs/transmit'

export default defineConfig({
	pingInterval: false,
	transport: null
})

Both values are deliberate.

pingInterval: false — no keepalive pings. Transmit can send periodic comments to hold a connection open through proxies that kill idle streams. The relevant proxy here is Caddy, which does not, and the payment page is open for a minute or two rather than for hours. Enabling it would mean traffic to every connected phone forever to solve a problem this deployment does not have.

transport: null — no cross-process transport. This is the important one.

Transmit can sync broadcasts between processes through Redis, so a broadcast from process A reaches a subscriber connected to process B. With null, a broadcast reaches only the subscribers connected to this process.

That is correct for a single-process deployment and actively wrong the moment you run two. If this app is ever scaled horizontally or run under a Node cluster, half the payment pages stop updating — silently, intermittently, and only under load. It is the kind of bug that takes a day to find because it is not reproducible on one machine.

The config is the correct choice today and a tripwire for a future deploy. Worth a comment in the file; worth knowing if you copy this setup.

Channels and Authorisation

Channel authorisation is declared in start/routes.ts, next to the routes:

transmit.registerRoutes((route) => {
	route.use(middleware.auth())
})

transmit.authorize<{ orderNumber: string }>('orders/:orderNumber', async (ctx, { orderNumber }) => {
	const user = ctx.auth.user
	if (!user) return false

	if (user.role === Role.STAFF) return true

	const order = await Order.query().where('order_number', orderNumber).first()
	return order?.userId === user.id
})

/**
 * The shop-wide order feed, for administrators only. It carries every order
 * that arrives, moves, or gets paid for, which is the whole business — not
 * something a customer or a staff member has any reason to subscribe to.
 */
transmit.authorize(ADMIN_ORDERS_CHANNEL, async (ctx) => {
	return ctx.auth.user?.role === Role.ADMIN
})

Three things worth pulling out.

The subscription endpoints are behind middleware.auth(). Anonymous clients cannot subscribe to anything, before any per-channel rule runs.

Channel names are patterns with parameters. orders/:orderNumber is one declaration covering every order, and the parameter arrives typed in the callback.

The rules are asymmetric on purpose. Staff get any order — they work on orders that are not theirs, that is the job. Customers get their own, checked against userId. Admins get the shop-wide feed and nothing else, because the per-order channel carries no information the dashboard feed does not.

The admin rule is one line and it is doing real work: the shop feed is every order in the business, and a staff member subscribing to it would see the whole book of work rather than the queue they are meant to be looking at.

Broadcasts Carry Values, Not Prose

All broadcasting goes through one service, and it holds a rule:

/**
 * Every payload carries stored values — statuses, types, amounts — and never
 * the Indonesian wording for them. A broadcast is data arriving at a screen
 * that already knows how to print it; baking the label in here would mean two
 * places decide what a status is called and one of them eventually drifts.
 */
export default class BroadcastService {
	orderChanged(order: Order, transactionStatus?: TransactionStatus): void {
		transmit.broadcast(orderChannel(order.orderNumber), {
			orderStatus: order.status,
			transactionStatus: transactionStatus ?? null
		})
	}
}

'awaiting_payment', never 'Menunggu Pelunasan'. Same rule as the transformers in Part 5, for the same reason: a page that receives a raw value can render it as a badge, a heading, or a receipt line, and renaming the Indonesian wording does not change what any page does.

The admin feed is one channel carrying three named events:

export const ADMIN_ORDERS_CHANNEL = 'admin/orders'

export const AdminOrderEvent = {
	/** A new order was booked or recorded at the counter. */
	CREATED: 'order:created',
	/** An existing order moved to a different status. */
	UPDATED: 'order:updated',
	/** An order's payment settled. */
	PAID: 'order:paid'
} as const
private toAdmin(event: AdminOrderEvent, order: Order): void {
  transmit.broadcast(ADMIN_ORDERS_CHANNEL, {
    event,
    orderNumber: order.orderNumber,
    customerName: order.customerName,
    status: order.status,
    type: order.type,
    totalPrice: order.totalPrice === null ? null : Number(order.totalPrice),
  })
}

One channel, three events, rather than three channels. One subscription, one connection, and the client switches on event.

And deliberately only three events. The comment on the constant:

/**
 * Deliberately one channel carrying three events rather than a live feed of
 * everything that happens in the shop. An admin needs to know when work
 * arrives, when it moves, and when money lands — the rest is detail they go
 * and look at, and pushing all of it would turn the dashboard into a firehose
 * that nobody can leave open.
 */

The failure mode of a realtime feed is not missing data. It is too much of it — a dashboard where things move constantly is a dashboard nobody watches, which is worse than one that updates on refresh. Choosing what not to broadcast is the design work.

Number(order.totalPrice) again, because numeric comes back as a string (Part 3). It is genuinely everywhere.

Where Broadcasts Come From

Three places, and each is the moment something became true:

// TransactionService — the Midtrans webhook confirmed payment
this.broadcastService.orderChanged(order, status)
if (status === TransactionStatus.PAID) {
	this.broadcastService.orderPaid(order)
}

// TaskService — a staff member claimed a pickup
if (type === ActionName.PICKUP) {
	this.broadcastService.orderChanged(fresh)
	this.broadcastService.orderUpdated(fresh)
}

// ReconciliationService — an admin settled a stuck payment by hand

The pickup claim broadcasting to the customer is my favourite one. A customer whose status flips to Dalam Penjemputan while they are looking at the page has learned that somebody is actually on the way — without a notification, an email, or a refresh. That is one line in the claim path, and it is most of the perceived responsiveness of the product.

Broadcasts happen after the transaction commits, never inside it:

await db.transaction(async (trx) => {
  await transaction.merge({ status, ... }).useTransaction(trx).save()

  if (status === TransactionStatus.PAID && order.status === OrderStatus.AWAITING_PAYMENT) {
    await order.merge({ status: OrderStatus.IN_CLEANING }).useTransaction(trx).save()
  }
})

this.broadcastService.orderChanged(order, status)

Broadcast inside the transaction and a rollback leaves every open page believing something that did not happen. The database is the source of truth; the broadcast is a notification that the truth changed. It has to come second.

The Client Side

const subscription = transmit.subscription('admin/orders')

Subscribe, listen, unsubscribe on unmount. The payment page does the same on its order’s channel, and does one thing that is easy to get wrong:

transactionStatus: string | null

if (!message.transactionStatus) return

The customer channel is shared by order changes and payment changes — a pickup claim broadcasts on it too. The payment page only cares about the payment half, so it ignores messages with no transaction status. Without that guard, a staff member claiming the pickup would make the payment page react to an event that has nothing to do with payment.

That is the cost of one channel carrying two kinds of message, and it is a small enough cost to be worth the single connection.

The page still works with realtime off. The status is a prop from the server render, and the SSE message updates it. Connection drops, phone sleeps, browser is ancient — the customer refreshes and sees the truth. Realtime is a live upgrade over a correct page, never the only way to learn something.

That principle is worth stating plainly because it is what makes the whole feature cheap: nothing depends on a broadcast arriving. Missing one costs a refresh. If a broadcast were the only path by which an order advanced, transport: null would not be a footnote about future scaling — it would be a data-loss bug.

What It Costs

One HTTP connection per open page. Node handles this well, and it is still a connection held for the duration of a visit. On this scale — a handful of staff and a customer or two on a payment page — it is nothing. At thousands of concurrent viewers it is a capacity question.

Single-process only, as configured. Covered above, and it is the one I would put a comment in the config file about.

No message history. SSE has a Last-Event-ID mechanism for replaying missed messages; Transmit does not use it here. A client that reconnects has missed whatever happened while it was gone. Acceptable exactly because the page is correct without the messages.

Testing it is awkward. The browser tests cover the pages, but “does a broadcast arrive at an open page” is not something I test — it needs two clients and timing assertions. The broadcast calls are covered; the delivery is trusted.

Would I Choose It Again

Yes, without much thought.

The requirement was one-directional, low-volume, and non-critical — a page that gets nicer when a message arrives and stays correct when one does not. SSE covers that with a config file of two lines, authorisation that reuses the session cookie, and reconnection I did not write.

The rule I would carry forward: pick the transport that matches the direction of the data. Bidirectional protocols for one-directional problems are a common way to buy a second set of failure modes and never use the capability you bought them for.


Next: Part 7 — Leaflet, Maps, and Geospatial Rules, where the marker does not move and the service area is not a circle.

© 2026 r3p.dev. All rights reserved.