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:
- 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.
- 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:
- Serve that file with Cache-Control: no-store
- Bump the SW cache name to purge old caches
- 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.
Top comments (0)