A point-of-sale web app in a busy restaurant is a hostile environment for the
assumptions most PWA tutorials make. The network drops behind the walk-in
fridge. Four devices edit the same table at the same time. And the one thing a
waiter will never do is wait for your app to finish reloading while a customer
is mid-sentence.
Here are three decisions from Servito, an
order-taking PWA for restaurants and pubs, that only look obvious after you've
watched the app being used during a dinner service.
1. Never call skipWaiting() automatically
The default snippet everyone copies makes a new service worker take over as
soon as it installs:
self.addEventListener("install", () => self.skipWaiting());
In a dining room this is a bug with a uniform on. A waiter is halfway through
a five-item order; you ship a deploy; the new worker activates, the page is
reloaded under them, and the order is gone. The customer is still talking.
So the install handler precaches and stops there. The new worker sits in
waiting until a client explicitly asks for it:
self.addEventListener("message", (event) => {
if (event.data === "SKIP_WAITING") self.skipWaiting();
});
The app surfaces a small "update available" banner, and the update happens when
the staff taps it — between tables, not between courses. Shipping becomes
boring, which is the goal.
2. Scope realtime channels to the venue, not the table
Multi-device is the whole point: the tablet at the pass, the phone in the
waiter's apron, the screen in the kitchen. Supabase Realtime makes the
subscription itself trivial:
const channel = supabase.channel(`floor-plan-${venueId}`);
channel.on(
"postgres_changes",
{ event: "*", schema: "public", table: "orders", filter: `venue_id=eq.${venueId}` },
() => refresh()
);
Two details matter more than the API surface.
The filter is server-side. venue_id=eq.${venueId} means the database
never pushes another restaurant's rows onto this socket. Filtering in the
client is not a performance optimisation you can skip — it's the difference
between a leak and a query.
One channel per view, named after what it shows. floor-plan-${venueId},
status-lanes-${venueId}. When something is stale in production, the channel
name tells you which screen to open. A single mega-channel that fans out to
every component is easy to write and impossible to debug at 9pm on a Saturday.
3. Offline means "degrade to something usable", not "queue everything"
Full offline write support for a POS is a distributed-systems project:
conflicting edits to the same bill, out-of-order sync, staff who reconcile
totals by hand anyway. What actually helps is a precached shell and an honest
offline page:
const PRECACHE_URLS = ["/", "/offline", "/manifest.webmanifest"];
The app tells the user the truth immediately instead of accepting input it may
silently lose. "The connection dropped, this order was not saved" is a
recoverable moment. "Your order was saved" followed by a missing order is how
you lose a customer for good — and in a restaurant, a lost order is a lost
table, not a lost row.
The pattern underneath
All three are the same trade: give up an optimisation that reads well in a
blog post, in exchange for behaviour that survives a room full of people who
did not agree to be your test users.
If you want to poke at the result, the live demo
runs without an account — it's the same build, with seeded data.
Top comments (0)