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.
Published 9 min read
On this page
- How does barcode scanning in the browser work?
- BarcodeDetector API browser support in 2026
- The WebAssembly fallback: ZXing in the browser
- Which barcode formats will you meet?
- Tips for fast, reliable camera scanning
- Do I need a camera at all? Keyboard-wedge scanners
- An inventory data model that survives scanning
- Two scan flows: look up, and log a movement
- 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?
- Secure context.
getUserMediaonly exists on HTTPS pages (andlocalhost). On a plainhttp://pagenavigator.mediaDevicesis undefined, so there is no camera to ask for. - 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. - Inline video. Attach the stream to a
<video>element withplaysinline. Without it, an iPhone plays the video fullscreen. - Frame sampling. On a timer, draw the current frame to a canvas, scaled down, and read its pixels.
- Decoding. Hand the pixels to a decoder. The first frame that yields a code wins.
- 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;
};
}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:
| Browser | BarcodeDetector | Notes |
|---|---|---|
| Chrome on Android | Yes, since Chrome 83 | Also Samsung Internet and Android WebView |
| Chrome on desktop | Partial | ChromeOS and macOS only; not Windows or Linux |
| Edge | Partial | macOS only |
| Safari on macOS and iOS | Behind a flag | Present since Safari 17 behind the Shape Detection API feature flag, off by default |
| Firefox | No | Not implemented |
| Other browsers on iPhone | Same as Safari | Apple 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?
| Format | Kind | Where you meet it |
|---|---|---|
| EAN-13 | 1D, 13 digits | Retail products outside North America, recognized at checkouts worldwide |
| UPC-A | 1D, 12 digits | Retail products in North America: groceries, electronics, toys |
| Code 128 | 1D, all 128 ASCII characters | Shipping labels (as GS1-128, which can carry batch, best-before date and quantity) and most internal bin and asset labels |
| Code 39 | 1D, digits, uppercase letters and 7 symbols | Older industrial labels: automotive (AIAG), US defense (LOGMARS), healthcare (HIBCC) |
| QR code | 2D | URLs, check-in codes, product links on packaging |
| Data Matrix | 2D, very compact | Pharmaceuticals 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-Policyheader, it must allow the camera, for examplecamera=(self), and an embedding iframe needsallow="camera". OtherwisegetUserMediarejects withNotAllowedError, the same error as a user saying no. - Test on a phone over HTTPS.
localhostcounts as a secure context, buthttp://192.168.1.20:3000on 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:
| Table | Key fields | Notes |
|---|---|---|
| Items | Name, SKU, Barcode, Unit, Reorder point | One row per thing you stock. Barcode is a text field, never a number: leading zeros matter. |
| Locations | Name, Code, Site | Shelves, bins, vans, job sites. The code is printed as a label. |
| Stock movements | Item, Location, Quantity, Type, Reference, By, At | Append-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.
| At | Location | Quantity | Type |
|---|---|---|---|
| Mon 08:10 | Warehouse A | +50 | Receipt |
| Mon 09:30 | Warehouse A | -6 | Transfer out |
| Mon 09:30 | Van 2 | +6 | Transfer in |
| Tue 14:05 | Van 2 | -2 | Used on job |
| Fri 16:00 | Warehouse A | -1 | Count adjustment |
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
- Scan an item or location label into a search field.
- Find the row whose Barcode equals the scanned text exactly.
- Open its record page: stock per location, recent movements, a photo, and buttons for the next action.
- If nothing matches, offer to create an item with that barcode already filled in.
Scan to log a movement
- Open a movement form with the Type preset: Receive, Pick or Transfer.
- Scan the location, then the item. Each scan fills one field.
- Default Quantity to 1 so most scans need no typing.
- 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
BarcodeDetectorwhen present, and otherwise a self-hostedzxing-wasmdecoder 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
- MDN: Barcode Detection API
- Can I use: BarcodeDetector
- MDN browser-compat-data: BarcodeDetector
- MDN: MediaDevices.getUserMedia()
- MDN: Permissions-Policy camera directive
- WebKit: New video policies for iOS (playsinline)
- zxing-wasm on GitHub
- barcode-detector polyfill on GitHub
- GS1 US: Types of barcodes
- Scandit: Code 39
- Scandit: Data Matrix
- Odoo: Barcode scanner setup
- Apple: App Review Guidelines, 2.5.6
Written by Apostolos Papadopoulos
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.