The problem

Small retail teams either buy a $100+ USB barcode scanner per workstation or type codes by hand. The scanners work, but they are tethered and priced per seat. Meanwhile everybody already carries a barcode reader in their pocket: a phone camera.

So the question became: what if the phone is the scanner, and the desktop browser is the sheet?

That became ScanSheet, a PWA I have been building on nights and weekends. This post is the engineering story.

Architecture in one paragraph

The desktop opens a browser tab with the table. The phone opens a browser tab with the camera. The desktop shows a QR code containing a one-time session token; the phone scans it and both sides connect to a WebSocket relay. Every scan streams to the desktop table in real time, quantities aggregate automatically, and the sheet exports to CSV/XLSX locally.

The relay is deliberately dumb: it never persists scan payloads. Scan data lives only in the two browsers (IndexedDB + localStorage).

Decoding: ZXing in the browser

import { BrowserMultiFormatReader } from "@zxing/browser"; const reader = new BrowserMultiFormatReader(); const controls = await reader.decodeFromVideoDevice(deviceId, videoEl, (result) => { if (result) sendScan(result.getText()); }); 

Two gotchas:

  1. Continuous decode fires many times per second on the same code. Debounce by text + timestamp (ignore duplicates within ~1.5s) and let the sheet aggregate quantity instead.
  2. Restricting formats (Code128, EAN-13, QR) instead of enabling everything noticeably improves accuracy on low-end Android cameras.

The relay: a dumb WebSocket

wss.on("connection", (ws, req) => { const session = req.url.match(/session=([\w-]+)/)?.[1]; ws.on("message", (data) => { for (const client of sessionClients(session)) { if (client !== ws && client.readyState === 1) client.send(data); } }); }); 

No database on this path, by design. If the server disappears, the phone queues scans in IndexedDB and flushes them when the socket reconnects. A warehouse has dead zones; the scanner must never block.

The Service Worker bug that ate my week

One day a feature shipped but returning visitors kept seeing the old version forever. The cause: sw.js served every .js file cache-first under a stable URL, so a script whose URL never changed was pinned to its first download for the life of the cache.

Fix, three layers deep:

  1. Serve that file with Cache-Control: no-store
  2. Bump the SW cache name to purge old caches
  3. Add a version query param to the script URL

Lesson: cache-first + a URL that never changes = a fossil. Version every mutable asset URL.

What I would do differently

  • Enable only the barcode formats you need; the kitchen-sink config costs accuracy on cheap cameras.
  • Test autofocus on low-end Android early; it varies wildly.
  • Keyboard shortcuts beat buttons for a data-entry tool: people's hands are on the scanner, not the mouse.

Try it

ScanSheet is free up to 1,000 rows and needs no sign-up: scan.skycang.com

Disclosure: this is my own product — the post above is the engineering story behind it.