Field teams

Barcode scanning in the browser: build an inventory app without a native app

How to scan barcodes in a web app: getUserMedia, BarcodeDetector API support in 2026, a ZXing WebAssembly fallback, scanning tips and an inventory data model.

Founder, Emberblocks

Published 9 min read

On this page
  1. How does barcode scanning in the browser work?
  2. BarcodeDetector API browser support in 2026
  3. The WebAssembly fallback: ZXing in the browser
  4. Which barcode formats will you meet?
  5. Tips for fast, reliable camera scanning
  6. Do I need a camera at all? Keyboard-wedge scanners
  7. An inventory data model that survives scanning
  8. Two scan flows: look up, and log a movement
  9. How Emberblocks handles barcode scanning

A web page can scan barcodes with no app to install. It opens the rear camera with getUserMedia (HTTPS only), copies a video frame to a canvas every 100 to 150 ms, and decodes it, either with the browser's built-in BarcodeDetector or with the ZXing library compiled to WebAssembly. As of October 2026 the built-in detector ships in Chrome on Android but not in Safari, Firefox or Chrome on Windows, so a scanner that must work on iPhones needs the WebAssembly fallback.

This guide covers the scanning pipeline, current browser support, the formats you will meet, the tips that make scanning fast, hardware scanners, and an inventory data model built around scans.

How does barcode scanning in the browser work?

  1. Secure context. getUserMedia only exists on HTTPS pages (and localhost). On a plain http:// page navigator.mediaDevices is undefined, so there is no camera to ask for.
  2. Rear camera. Request video with facingMode: "environment" as an ideal rather than an exact constraint, so a laptop with only a front camera still works.
  3. Inline video. Attach the stream to a <video> element with playsinline. Without it, an iPhone plays the video fullscreen.
  4. Frame sampling. On a timer, draw the current frame to a canvas, scaled down, and read its pixels.
  5. Decoding. Hand the pixels to a decoder. The first frame that yields a code wins.
  6. Clean up. Stop every camera track when the scan succeeds or the dialog closes. That turns the camera indicator off and saves battery.
// Open the rear camera, then try to decode a frame every 120 ms.
async function startScanner(video, onCode) {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: { facingMode: { ideal: "environment" } },
    audio: false,
  });
  video.srcObject = stream;
  video.playsInline = true; // iPhone: play inline, not fullscreen
  video.muted = true;
  await video.play();

  const decode = await makeDecoder(["ean_13", "upc_a", "code_128", "qr_code"]);
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d", { willReadFrequently: true });
  let stopped = false;

  async function tick() {
    if (stopped) return;
    const w = video.videoWidth;
    const h = video.videoHeight;
    if (w && h) {
      const scale = Math.min(1, 800 / Math.max(w, h)); // long side at most 800 px
      canvas.width = Math.round(w * scale);
      canvas.height = Math.round(h * scale);
      ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
      const code = await decode(ctx.getImageData(0, 0, canvas.width, canvas.height));
      if (code && !stopped) return onCode(code);
    }
    setTimeout(tick, 120);
  }
  tick();

  return function stop() {
    stopped = true;
    stream.getTracks().forEach((track) => track.stop());
  };
}

async function makeDecoder(formats) {
  if ("BarcodeDetector" in window) {
    const supported = await BarcodeDetector.getSupportedFormats();
    if (formats.every((f) => supported.includes(f))) {
      const detector = new BarcodeDetector({ formats });
      return async (img) => (await detector.detect(img))[0]?.rawValue ?? null;
    }
  }
  // Fallback: ZXing compiled to WebAssembly, loaded only when needed.
  const zxing = await import("zxing-wasm/reader");
  zxing.prepareZXingModule({
    overrides: {
      // Serve the .wasm yourself instead of the default CDN.
      locateFile: (path, prefix) => (path.endsWith(".wasm") ? "/zxing_reader.wasm" : prefix + path),
    },
  });
  const names = { ean_13: "EAN-13", upc_a: "UPC-A", code_128: "Code128", qr_code: "QRCode" };
  return async (img) => {
    const hits = await zxing.readBarcodes(img, {
      formats: formats.map((f) => names[f]),
      maxNumberOfSymbols: 1,
    });
    return hits[0]?.text ?? null;
  };
}
A minimal scanner: native BarcodeDetector when it supports the formats you need, zxing-wasm otherwise.

BarcodeDetector API browser support in 2026

The Barcode Detection API (new BarcodeDetector({ formats }), then detect(image)) is still marked experimental, with limited availability, on MDN. Support as of October 2026, from MDN's compatibility data and Can I use:

BrowserBarcodeDetectorNotes
Chrome on AndroidYes, since Chrome 83Also Samsung Internet and Android WebView
Chrome on desktopPartialChromeOS and macOS only; not Windows or Linux
EdgePartialmacOS only
Safari on macOS and iOSBehind a flagPresent since Safari 17 behind the Shape Detection API feature flag, off by default
FirefoxNoNot implemented
Other browsers on iPhoneSame as SafariApple requires iOS browsers to use WebKit, apart from alternative-engine entitlements in the EU and Japan

Feature-detect at runtime, and do not stop at checking that BarcodeDetector exists: call BarcodeDetector.getSupportedFormats() and fall back when a format you need is missing. In practice that means Android phones decode natively, and iPhones, Windows laptops and Firefox users get the fallback.

The WebAssembly fallback: ZXing in the browser

ZXing is the long-standing open-source barcode library. The zxing-wasm package compiles its C++ port to WebAssembly and reads 1D codes (EAN/UPC, Code 128, Code 39, ITF, Codabar and more) and 2D codes (QR, Data Matrix, Aztec, PDF417). Its readBarcodes() accepts the ImageData you just read from your canvas.

Three practical notes. The reader-only build is about 1 MiB of WebAssembly, so load it with a dynamic import() the first time someone taps Scan, not on page load. By default the package fetches its .wasm file from the jsDelivr CDN; pass a locateFile override to prepareZXingModule() to serve it from your own domain, which avoids a third-party request and keeps working on networks that block CDNs. And if you prefer to code against one interface, the barcode-detector package is a polyfill that exposes the standard BarcodeDetector API on top of zxing-wasm.

Which barcode formats will you meet?

FormatKindWhere you meet it
EAN-131D, 13 digitsRetail products outside North America, recognized at checkouts worldwide
UPC-A1D, 12 digitsRetail products in North America: groceries, electronics, toys
Code 1281D, all 128 ASCII charactersShipping labels (as GS1-128, which can carry batch, best-before date and quantity) and most internal bin and asset labels
Code 391D, digits, uppercase letters and 7 symbolsOlder industrial labels: automotive (AIAG), US defense (LOGMARS), healthcare (HIBCC)
QR code2DURLs, check-in codes, product links on packaging
Data Matrix2D, very compactPharmaceuticals and medical devices (GS1 DataMatrix), and parts marked directly by laser or dot peen in electronics and manufacturing

Limit the decoder to the formats you actually expect. It decodes faster and misreads less, especially between similar 1D symbologies. For labels you print yourself, use Code 128 for short IDs such as bins and asset tags, and QR for anything long or for a URL that opens the record. Leave EAN and UPC to products that already carry them.

Tips for fast, reliable camera scanning

  • Downscale before decoding. A full-resolution frame is mostly wasted pixels. Scaling the long side to about 800 px keeps barcodes readable and makes each decode much cheaper.
  • Sample every 100 to 150 ms, not every animation frame. Seven to ten attempts a second feels instant and keeps the phone cool. Schedule the next attempt only after the previous decode finishes.
  • Debounce duplicate reads. In continuous mode the same code shows up on several frames in a row. Ignore a repeat of the last code for a second or two, and confirm each accepted scan with a sound or a flash.
  • Keep manual entry. Damaged labels, a denied permission, a desktop without a camera: the scan button should sit beside a normal text input, never replace it.
  • Watch light and glare. Glossy labels behind plastic reflect light into a white blob. Tilt the phone slightly, hold it far enough away to focus, and print labels on matte stock.
  • Allow the camera in Permissions-Policy. If your server sends a Permissions-Policy header, it must allow the camera, for example camera=(self), and an embedding iframe needs allow="camera". Otherwise getUserMedia rejects with NotAllowedError, the same error as a user saying no.
  • Test on a phone over HTTPS. localhost counts as a secure context, but http://192.168.1.20:3000 on your phone does not, so the camera API is simply missing during local testing. Use a tunnel or a local certificate.

Do I need a camera at all? Keyboard-wedge scanners

For heavy scanning, a dedicated scanner beats a phone camera, and it needs no special API. USB and Bluetooth scanners in HID keyboard mode (often called keyboard wedge) type the decoded code into whatever text field has focus, and can be set to press Enter after each code. So they work with every web app, including ones that know nothing about barcodes.

To make a form scanner-friendly, autofocus the code field, treat Enter in that field as look up or submit, and put focus back after each scan. Set the scanner's keyboard layout to match the device's, or some characters come out wrong; Odoo's scanner setup guide lists the settings worth checking.

An inventory data model that survives scanning

Most homemade inventory apps start with an Items table and a Quantity column that people edit. It breaks the first time two people scan the same item at once, and it cannot tell you where the stock went. Use three tables instead:

TableKey fieldsNotes
ItemsName, SKU, Barcode, Unit, Reorder pointOne row per thing you stock. Barcode is a text field, never a number: leading zeros matter.
LocationsName, Code, SiteShelves, bins, vans, job sites. The code is printed as a label.
Stock movementsItem, Location, Quantity, Type, Reference, By, AtAppend-only. Positive quantities in, negative out: receipts, picks, transfers (two rows), count adjustments.

Current stock is the sum of movements for an item (or for an item at one location), not a number anyone types. That gives you two things a quantity field cannot:

  • An audit trail. Every change records who, when, where and why. "We had 40 on Monday" becomes a query instead of an argument.
  • No lost updates. If two people each add 1 to a stored quantity of 10 at the same moment, both save 11 and one scan disappears. Two inserted movements are two rows, and the sum is 12.
AtLocationQuantityType
Mon 08:10Warehouse A+50Receipt
Mon 09:30Warehouse A-6Transfer out
Mon 09:30Van 2+6Transfer in
Tue 14:05Van 2-2Used on job
Fri 16:00Warehouse A-1Count adjustment
Movements for one item, M8 hex bolts (box of 100). On hand: Warehouse A 50 - 6 - 1 = 43, Van 2 6 - 2 = 4.

Corrections are movements too: a count that finds 43 where the sum says 44 writes an adjustment of -1. Append-only data also suits phones that go offline, because queued inserts cannot overwrite each other even when sync is last write wins (more in offline web apps for field teams).

Two scan flows: look up, and log a movement

Scan to look up

  1. Scan an item or location label into a search field.
  2. Find the row whose Barcode equals the scanned text exactly.
  3. Open its record page: stock per location, recent movements, a photo, and buttons for the next action.
  4. If nothing matches, offer to create an item with that barcode already filled in.

Scan to log a movement

  1. Open a movement form with the Type preset: Receive, Pick or Transfer.
  2. Scan the location, then the item. Each scan fills one field.
  3. Default Quantity to 1 so most scans need no typing.
  4. Save, confirm clearly, and go straight back to step 2 for the next item.

Whatever you print should be something your lookup can match exactly: the item's barcode value, or a URL ending in the record's ID so any phone camera opens the right page.

How Emberblocks handles barcode scanning

Scanning in Emberblocks follows the approach above. What we built:

  • Any text, email, phone or URL field can turn on camera scanning, set to any code, barcodes only, or QR only. The field gets a Scan button, and typing still works.
  • Decoder. The scanner uses the browser's native BarcodeDetector when present, and otherwise a self-hosted zxing-wasm decoder served from our own domain rather than a CDN.
  • Sampling. It samples a frame roughly every 120 ms, downscaled to about 800 px.
  • Secure context. The camera needs HTTPS. Without it, the scan dialog explains why instead of failing silently.

The other direction is the barcode block, which displays a code: it turns a formula's value into a QR code or a Code 128 barcode, can print the value underneath, always sits on a white plate for contrast, and downloads as a print-resolution PNG for labels. Code 128 there takes plain ASCII only, so use QR for accented or non-Latin text.

For the data model, a SUM expression rolls up a child table, so an Items row can total its stock movements, and the forms guide covers building the movement form. Published apps install on a phone's home screen and queue edits made offline until the connection is back. The inventory template and the asset tracking template are starting points, and the inventory app builder page has the full feature list.

Where it is not the right fit: the camera scanner reads one code into one field per scan, so it is not built for reading a pallet of labels in one frame or for very high scan volumes. For that, pair a keyboard-wedge scanner with the same forms, or use a dedicated warehouse system. Emberblocks has no App Store or Play Store apps, so scanning always runs in the browser.

Questions people ask

Can a website scan barcodes without an app?
Yes. Any HTTPS page can open the phone's camera with getUserMedia and decode frames with the BarcodeDetector API or a WebAssembly decoder such as zxing-wasm. The user grants camera permission once; nothing is installed.
Does the BarcodeDetector API work on iPhone?
Not by default, as of October 2026. Safari has it behind the Shape Detection API feature flag, which is off, and other iPhone browsers use the same WebKit engine. Use a WebAssembly decoder as the fallback.
Why won't the camera open on my scanning page?
Usually one of four things: the page is not HTTPS, the user denied permission, a Permissions-Policy header or an iframe without allow="camera" blocks it (both reject with NotAllowedError), or the system could not open the camera (NotReadableError).
Do Bluetooth or USB barcode scanners work with web apps?
Yes. In HID keyboard mode they type the code into the focused field and can press Enter after it, so they work in any browser and any web app without extra code.
Which barcode format should I print on my own labels?
Code 128 for short IDs such as bins and asset tags, and QR codes for URLs or longer text. EAN-13 and UPC-A are GS1 retail product numbers, not a format for internal labels.

Sources

  1. MDN: Barcode Detection API
  2. Can I use: BarcodeDetector
  3. MDN browser-compat-data: BarcodeDetector
  4. MDN: MediaDevices.getUserMedia()
  5. MDN: Permissions-Policy camera directive
  6. WebKit: New video policies for iOS (playsinline)
  7. zxing-wasm on GitHub
  8. barcode-detector polyfill on GitHub
  9. GS1 US: Types of barcodes
  10. Scandit: Code 39
  11. Scandit: Data Matrix
  12. Odoo: Barcode scanner setup
  13. Apple: App Review Guidelines, 2.5.6

Written by

Apostolos builds Emberblocks, a visual app builder for internal tools on your own data, from Thessaloniki, Greece. Spotted something wrong or out of date? Write to hello@emberblocks.com.

Keep reading

Ready to build on your own data?

Connect Postgres, Airtable or a spreadsheet export and publish a working app your team can use today. Builders are always free.