DEV Community

skycang
skycang

Posted on

How I built a PWA barcode scanner that pairs your phone to a desktop spreadsheet

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());
});
Enter fullscreen mode Exit fullscreen mode

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);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)