Every location, live
A US salon chain was scaling into a franchise. It ran on the usual mix a growing business accumulates: consumer scheduling apps, POS terminals that each knew only their own counter, and customer records kept in whichever tool happened to touch them last. From head office there was no live picture of what any location was doing, which made it hard to manage services centrally or plan the next store on numbers anyone could trust.
The mandate was one platform for the whole chain, built for franchise scale rather than retrofitted into it. Four surfaces had to run off the same backend at once: an admin dashboard for franchise owners, a point-of-sale for the salon floor, a public booking site for customers, and a back office for configuration and reporting. Payments run on Stripe Terminal at the counter, so a checkout that stalls is a customer standing at the desk with a card in hand. The word that shaped the architecture, though, was “live.” A franchise owner watching one dashboard, and two front-desk staff at the same salon working off separate terminals, all had to see the same day as it happened.
A room per store
The easy way to get live updates is to push everything over a socket and hope the clients keep up. That falls apart the moment there is more than one store, because a check-in in Dallas has no business waking a terminal in Houston. So the realtime layer is partitioned by store from the first line.
Every terminal, once it knows which store it belongs to, sends that store id to the backend and joins a Socket.IO room named for it. An empty id gets the socket dropped rather than parked in some default room where it could leak another location’s traffic. When a new check-in lands on any terminal, the backend broadcasts to that one room, and only the terminals inside it hear anything.
// One Socket.IO room per store, keyed by storeId. The broadcast carries no
// payload on purpose: it is a nudge, and each terminal refetches from Postgres,
// which stays the single source of truth. Scoping by store keeps a check-in in
// one salon from waking terminals in another.
@WebSocketGateway()
export class CheckInGateway {
@WebSocketServer() server: Server;
@SubscribeMessage('check-in')
async joinStoreRoom(client: Socket, storeId: string) {
if (!storeId) {
client.disconnect(); // no store means no room to belong to
return;
}
await client.join('check-in-' + storeId);
}
emitNewCheckIn(storeId: string) {
this.server.to('check-in-' + storeId).emit('new-check-in');
}
} The broadcast that carries nothing
The broadcast is worth a closer look, because of what it leaves out. When a customer checks in, the backend writes the row and then emits new-check-in to the store room with no payload at all. It carries no state, only a signal to go and look. Each terminal that hears it refetches the check-in list from the API, so PostgreSQL stays the one place the truth lives and no client is ever rendering a check-in it assembled from a socket message that might have arrived out of order.
That keeps the moving parts honest. The gateway never has to serialize a check-in, terminals never drift from the database, and a terminal that reconnects after a dead spot pulls the current list on its next refetch instead of replaying a backlog of events it missed while it was gone.
When the same customer checks in twice
Two terminals at one desk will eventually try to check the same person in at the same time. Before it writes anything, the backend looks for an existing check-in for that customer at that store on the current day, read in the store’s own timezone, and refuses a second open one with a plain “already checked in” instead of creating a duplicate that staff would have to untangle at checkout. The timezone detail matters more than it looks, since a chain spread across zones cannot decide what “today” means from the server’s clock.
The other end of the day gets handled without anyone remembering to. A guest who never checks out would otherwise leave a check-in open forever, so a nightly job sweeps the previous day and closes whatever is still hanging, stamping it just before that store’s midnight so the books land on the right date. Deactivating a store or a service never hard-deletes it either. MikroORM soft deletes keep the row alive underneath the past invoices and reports that still reference it, so history stays intact while the thing drops off today’s menus.
Sequencing seven engineers
I directed seven engineers against a POS-first plan: a backend lead on the NestJS API and schema, three frontend engineers each owning a surface, a fullstack engineer bridging the back office, and a QA engineer holding integration coverage. Checkout throughput is where the salon earns, so a working POS on Stripe Terminal was milestone one, and the admin and booking surfaces shipped in parallel once the auth, store, and RBAC foundations were stable. Keeping all four apps in one monorepo behind shared ts-rest and Zod contracts meant a breaking change to an API route surfaced as a type error across every app before it reached staging, rather than as a bug a front desk found first.
Connectivity on a salon floor is never guaranteed, so the POS can hold a sale in progress locally through a network drop and complete it once the connection returns, so a busy counter never loses a sale to a dead spot. That resilience sat next to the realtime sync as the two things the floor would notice if they broke, which is why both were built and reviewed before go-live rather than bolted on after.
Outcomes
- 4 apps
- ts-rest
- WebSocket
- Park-sale
- 7 engineers
- POS-first
- Monorepo
- Franchise-ready