Tapirscan for JavaScript and TypeScript
Scan barcodes in the browser or Node with a Rust/WASM core. Each scan returns
every decoded barcode with its text, format and source-image polygon / rect.
tapirscan/browserfor web apps: scan files,<img>,<video>, canvases or bitmaps in a bundled worker, with no WASM setup.tapirscan(the core) for Node and decoded pixels, scanning synchronously on the calling thread.
Try the live demo · Browser apps · Core API · WASM loading · All options · Results
npm install tapirscan
TypeScript declarations and WASM binaries are included. Your runtime must support WebAssembly SIMD; Node 20 or later is required.
Browser apps, React and Svelte
import { scan } from "tapirscan/browser";
const result = await scan(file); // a File from <input type="file">
console.log(result.values); // e.g. ["4006381333931"]
Reuse a scanner for several images or camera frames. Construction is synchronous: the worker and WASM load in the background, and the first scan waits for them.
import { Scanner } from "tapirscan/browser";
const scanner = new Scanner({ formats: ["EAN13", "QRCode"] });
const result = await scanner.scan(image); // a file, <img>, canvas, video frame, ...
scanner.dispose(); // stops the worker
Camera: scan a playing <video> in a loop. Waiting for the next frame scans
each frame at most once, and awaiting each scan means slow devices skip frames
instead of falling behind:
while (running) {
await new Promise((resolve) => video.requestVideoFrameCallback(resolve));
if (!running) break;
const result = await scanner.scan(video);
// ...show result.values and result.barcodes
}
dispose() rejects the scan in progress with "Scanner was disposed". In React
(add "use client"; at the top in Next.js):
import { useEffect, useRef, useState } from "react";
import { Scanner } from "tapirscan/browser";
export default function BarcodeScanner() {
const video = useRef(null);
const [barcodes, setBarcodes] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const element = video.current;
const scanner = new Scanner();
const camera = navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
let running = true; // React StrictMode mounts twice in development
// One cleanup for unmounting and for failures: stop scanning and the camera.
const stop = () => {
running = false;
scanner.dispose();
camera.then(
(stream) => stream.getTracks().forEach((track) => track.stop()),
() => {}, // No stream to stop.
);
};
camera
.then(async (stream) => {
if (!running) return;
element.srcObject = stream;
while (running) {
await new Promise((resolve) => element.requestVideoFrameCallback(resolve));
if (!running) break;
const found = await scanner.scan(element);
if (running) setBarcodes(found.barcodes);
}
})
.catch((cause) => {
if (running) setError(cause.message); // camera denied, loading or scan failure
stop();
});
return stop;
}, []);
return (
<>
<video ref={video} autoPlay muted playsInline />
{error && <p role="alert">{error}</p>}
{barcodes.map((barcode, i) => (
<p key={i}>
{barcode.format}: {barcode.text}
</p>
))}
</>
);
}
In Svelte 5 and SvelteKit:
<script>
import { Scanner } from "tapirscan/browser";
import { onDestroy } from "svelte";
const scanner = new Scanner();
onDestroy(() => scanner.dispose());
let barcodes = $state.raw([]);
let error = $state(null);
let latest = 0; // scans can finish out of order; keep only the newest
async function onchange(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
const request = ++latest;
try {
const found = await scanner.scan(file);
if (request === latest) [barcodes, error] = [found.barcodes, null];
} catch (cause) {
if (request === latest) [barcodes, error] = [[], cause.message];
}
}
</script>
<input type="file" accept="image/*" {onchange} />
{#if error}<p role="alert">{error}</p>{/if}
{#each barcodes as barcode}<p>{barcode.format}: {barcode.text}</p>{/each}
Bundlers: Next.js (Turbopack or webpack), Vite 8 and production builds bundle the worker and WASM files with no configuration. The Vite 6 and 7 development servers (including SvelteKit on them) need one line, or the scanner reports that its worker failed to start:
// vite.config.js
export default defineConfig({
// ...your plugins
optimizeDeps: { exclude: ["tapirscan"] },
});
CI builds the Vite example from the packed package and scans an uploaded photo with the production build in Chrome. The Next.js and Vite 7–8 setups were verified by hand.
- Sources: a
FileorBlob(any image the browser decodes),<img>,<video>(its current frame),<canvas>,OffscreenCanvas,ImageBitmap,VideoFrame,ImageData, or decoded pixels as in the core API. Inputs are not modified or transferred. Images and canvases are composited onto white, so transparent areas count as background;ImageDataand pixel buffers are scanned as given, with alpha ignored. - Methods:
scanandinspectwork as in the core API but return promises. Each call captures its image and options when it starts, so buffers and option objects can be reused immediately. Calls reach the worker in call order. - Options:
mode,formats,eanAddOnPolicyandexperimentalTurbowork as in the core, andscan(image, { formats })overrides the formats for one call.wasmBaseUrlserves the WASM files from another directory.loadWasmneeds the core. - Lifecycle:
scanner.readyresolves once loaded; awaiting it is optional, because loading errors also reject every scan. During server rendering the constructor does nothing and scans reject.dispose()stops the worker and immediately rejects every unfinished scan; returned results stay valid. - Errors: unknown option names and invalid
wasmBaseUrlvalues throw in the constructor; invalid option values and scan arguments reject withTypeError; engine failures reject withScannerError. Browser image decoding and loading can also fail. - Requirements: module workers,
OffscreenCanvasand WebAssembly SIMD: Chrome 91, Firefox 114, Safari 16.4 or later. The camera loop also needsrequestVideoFrameCallback. Images may have at most 32 megapixels. - Deployment size: bundlers emit all eight WASM files (four modes and four Turbo presets, 1.8–2.5 MB each, about 16 MB together), so any can be selected without configuration. A page downloads only the one its scanner uses.
The camera example is a complete page: from this
directory (or the installed package), run python3 -m http.server and open
/examples/camera.html on localhost. Camera capture requires HTTPS; localhost
works for development.
Core API
The core scans decoded pixels, for example a canvas's ImageData:
import { scan } from "tapirscan";
const image = context.getImageData(0, 0, canvas.width, canvas.height);
const result = await scan(image);
console.log(result.values); // e.g. ["4006381333931"]
The one-shot helper creates and disposes a scanner. Defaults are Medium effort and retail formats. In browsers the core needs its WASM assets served; Node loads the packaged files automatically.
Reuse a scanner for repeated images. Its scans are synchronous:
import { Scanner } from "tapirscan";
const scanner = await Scanner.create({ mode: "high", formats: "1D" });
try {
const result = scanner.scan(image);
for (const barcode of result.barcodes) {
console.log(barcode.text, barcode.format, barcode.polygon);
}
} finally {
scanner.dispose();
}
Settings also work with the helper: await scan(image, { mode: "high", formats: "1D" }).
For unread regions, timing and diagnostics, call inspect instead of scan.
The core scans synchronously, so a long scan on the main thread blocks the page.
Use tapirscan/browser, or create one core scanner inside your own worker and
transfer frame buffers to it; configure loadWasm inside that worker.
| Function | Returns | Behavior |
|---|---|---|
scan(image, options = {}) |
Promise<ScanResult> |
One image; creates and disposes a scanner. |
inspect(image, options = {}) |
Promise<InspectionResult> |
Like scan, with timing, unread regions and diagnostics. |
Scanner.create(options = {}) |
Promise<Scanner> |
A reusable scanner with fixed mode, default formats and supplement policy. |
scanner.scan(image, { formats }) |
ScanResult |
Synchronous scan; formats optionally overrides the defaults for this call. |
scanner.inspect(image, { formats }) |
InspectionResult |
Synchronous inspection. |
scanner.dispose() |
void |
Releases WASM memory. Repeated disposal is safe; do not scan after disposal. |
Create another scanner to change effort or the supplement policy. Per-call
formats such as scanner.scan(image, { formats: "QRCode" }) apply only to that
call; scanner.formats exposes the defaults. Results stay valid after disposal.
Experimental Turbo presets
For faster 1D barcode scanning, opt into a Turbo preset instead of a mode. Both entries accept it; in the browser it suits camera loops:
import { Scanner } from "tapirscan/browser";
const scanner = new Scanner({ experimentalTurbo: 2, formats: "retail" });
const result = await scanner.scan(video);
Accepted values are 2, 4, 8 and 16. Each roughly indicates the speedup over Low it targets; the actual speedup varies with images and devices and is not guaranteed. Higher presets do less work and can miss more barcodes, including clean symbols placed close together. Start with 2 and check detection on your own inputs. The presets speed up EAN-13, UPC-A, EAN-8, UPC-E, Code 128, Code 39 and ITF. Other formats stay readable when selected, but do not get faster, and mixed-format scans still pay for the enabled 2D readers. See Turbo behavior and limitations.
experimentalTurbo and mode are mutually exclusive, and Turbo requires
eanAddOnPolicy: "ignore". A Turbo scanner has no effort mode: inspect results
report experimentalTurbo and omit mode. The core scanner also exposes
scanner.experimentalTurbo and leaves scanner.mode undefined; the browser
Scanner has no configuration properties.
Stability: this option, its presets and their asset imports may change or be removed in a minor release. Pin the exact package version if you rely on them.
tapirscan/browser loads the preset's WASM by itself. With the core in Vite or
SvelteKit, load the matching asset as in WASM loading:
tapirscan/wasm/experimental-turbo2.wasm, -turbo4, -turbo8 or -turbo16.
WASM loading
In Node, the default loader reads assets from the installed package. Decode your image with an image library first, then pass grayscale, RGB or RGBA bytes:
import { scan } from "tapirscan";
const result = await scan({ data: pixels, width, height, channels: 1, stride: width });
Here pixels is a Uint8Array of decoded grayscale pixels. Filenames, URLs and
encoded JPEG/PNG bytes are not scan inputs.
Vite and SvelteKit
tapirscan/browser needs no WASM setup. To use the
core directly, import the WASM asset URL so Vite includes it in development and
production builds, including apps deployed under a base path:
import { Scanner } from "tapirscan";
import mediumWasmUrl from "tapirscan/wasm/medium.wasm?url";
// Load once; reuse these bytes if you create more than one scanner.
const response = await fetch(mediumWasmUrl);
if (!response.ok) throw new Error(`WASM load failed: ${response.status}`);
const bytes = await response.arrayBuffer();
const scanner = await Scanner.create({ loadWasm: async () => bytes });
The imports are tapirscan/wasm/low.wasm, medium.wasm, high.wasm and
very-high.wasm; match the asset to mode (the example uses the default
Medium). A mismatched asset is an error. ?url is Vite syntax. In SvelteKit,
create core scanners in browser code such as onMount, not during server rendering.
Other browser setups
The default loader fetches assets relative to the module. If your bundler relocates modules, copy the WASM files to your public directory as part of your build, so package upgrades cannot leave stale assets:
mkdir -p public/tapirscan
cp node_modules/tapirscan/wasm/*.wasm public/tapirscan/
Then point the scanner at that directory:
const scanner = await Scanner.create({ wasmBaseUrl: "/tapirscan/" });
wasmBaseUrl accepts a string or URL, with or without a trailing slash. Relative
URLs resolve against the page or worker in browsers and the package module in
Node. For authenticated requests or custom storage, use
loadWasm: async (url) => arrayBuffer; it receives URLs resolved against
wasmBaseUrl. The default loader caches loaded assets within a module instance;
custom loaders manage their own caching. Each scanner owns its own WASM instance.
All options
| Option | Where | Default | Meaning |
|---|---|---|---|
mode |
Creation | "medium" |
"low", "medium", "high", "very-high". |
formats |
Creation / scan | "retail" |
An identifier, "retail", "common1D", "common", "1D", "2D", "all", or a nonempty array. Per-call formats override the defaults. |
eanAddOnPolicy |
Creation | "ignore" |
"ignore", "read", "require"; see supplements. |
wasmBaseUrl |
Creation | Module-relative assets | Directory URL for the packaged WASM files. |
loadWasm |
Creation | Module-relative loader | (url: URL) => Promise<ArrayBuffer>. |
experimentalTurbo |
Creation | Unset | Experimental: 2, 4, 8 or 16. Mutually exclusive with mode. |
"retail" selects EAN13, UPCA, EAN8 and UPCE. "common1D" adds Code128,
Code39 and ITF; "common" adds QRCode and DataMatrix. "1D" and "2D"
enable all linear or all 2D formats.
The exports retailFormats, commonLinearFormats, commonFormats,
linearFormats and matrixFormats help compose custom selections. See
format coverage for identifiers and variants.
Image input
The core accepts ImageData (or an object with its data, width and height)
or an explicit pixel buffer:
| Field | Type | Meaning |
|---|---|---|
data |
Uint8Array |
Decoded pixels. ImageData uses Uint8ClampedArray with tightly packed RGBA. |
width, height |
number |
Integer dimensions, at least 3 pixels each. |
channels |
1 | 3 | 4 |
Grayscale, RGB or RGBA. Alpha is ignored. Required for explicit buffers. |
stride |
number |
Bytes between row starts; defaults to width × channels. Larger values allow padding. |
Input is limited to 32 megapixels and 128 MiB of addressed pixels. Keep the buffer unchanged during the call. Coordinates start at the top left, x rightward and y downward. If you resize or rotate before scanning, map coordinates back yourself.
Results
scan returns a ScanResult; inspect returns an InspectionResult with the
same fields plus the ones marked inspect.
| Field | Type | Meaning |
|---|---|---|
result.values |
readonly string[] |
Decoded strings, one per barcode. |
result.barcodes |
readonly Barcode[] |
Decoded barcodes with format and geometry. |
result.best |
Barcode | undefined |
Highest-support barcode, first on ties. |
result.image |
{ width, height } |
inspect: dimensions of the supplied pixels. |
result.mode |
Mode | undefined |
inspect: effort mode used; absent for Turbo presets. |
result.elapsedMs |
number |
inspect: scan time, excluding image loading and scanner creation. |
result.undecoded |
readonly UndecodedRegion[] |
inspect: regions without a decode. |
result.diagnostics |
Diagnostics |
inspect: engine evidence. |
barcode.text |
string |
Decoded text. |
barcode.format |
Format |
Symbology identifier. |
barcode.polygon |
Quad |
Four [x, y] corners in input-image coordinates. |
barcode.rect |
{ left: number, top: number, width: number, height: number } |
Enclosing integer rectangle. |
barcode.support |
number |
Reader-specific ranking evidence used by best. |
barcode.payloadBytes |
readonly number[] | undefined |
Decoded matrix payload bytes, when available. |
barcode.gs1 |
boolean | undefined |
GS1 indicator, when supplied by the reader. |
barcode.readerInitialization |
boolean | undefined |
Data Matrix, PDF417, Aztec and MaxiCode reader-initialization flag. |
barcode.structuredAppend |
StructuredAppend | undefined |
Multipart metadata: one-based index, count, optional id and parity. |
barcode.eanAddOn |
string | undefined |
EAN/UPC supplement, with eanAddOnPolicy "read" or "require". |
Separate labels with the same value stay separate entries. Results are deeply
frozen; use structuredClone(result) for a mutable copy. JSON.stringify(result)
gives plain JSON.
support is an uncalibrated, reader-specific ranking heuristic, not a
probability, and is not comparable across formats. In a mixed-format image,
best is therefore not necessarily the most reliable read; select by format or
payload when your application knows what it expects. Checksums reduce wrong
reads but cannot rule them out.
payloadBytes is supplied by QR Code, Data Matrix, Aztec, PDF417 and MaxiCode.
It holds decoded data bytes before character-set interpretation, not raw
codewords; Aztec Rune gives its value as decimal ASCII. Encoding text as UTF-8
does not reconstruct these bytes. Use Uint8Array.from(barcode.payloadBytes)
for a byte buffer.
All types (ScanResult, InspectionResult, Barcode, ScannerOptions,
ScanOptions, Format and others) are exported from both entries.
EAN/UPC supplements
Set eanAddOnPolicy when creating a scanner or calling one-shot scan or inspect.
| Policy | Behavior |
|---|---|
"ignore" |
Decode the main barcode without reading its supplement. |
"read" |
Try reading the two- or five-digit supplement; keep the main barcode if none is readable. |
"require" |
Return an EAN/UPC barcode only when its supplement is readable. Other formats remain unaffected. |
The supplement appears in barcode.eanAddOn; barcode.text is the main payload,
and polygon and rect describe the main barcode. Reading supplements adds
decoding work. Retail reads rejected by "require" appear in result.undecoded.
Undecoded regions
inspect results list undecoded regions: localized proposals without an
accepted decode, each with a source-image polygon and a format hint. They
can overlap or be false candidates, and an empty list does not prove that every
barcode was found.
Diagnostics
const report = scanner.inspect(image);
console.log(report.diagnostics.regions.proposals, report.diagnostics.regions.searchWindows);
console.log(report.diagnostics.scan.barcodes);
diagnostics.regions has a stable shape: proposals and searchWindows are
null when a reader does not expose them, and empty arrays when it found nothing.
The other fields are the engine's own evidence; they vary by reader and may
change between releases. Candidate indices inside recovery crops are local to
the crop, not identifiers for tracking between frames.
Errors
Invalid options throw TypeError. Engine and validation failures throw the
exported ScannerError with .message and a .code:
| Code | Meaning |
|---|---|
"invalid_input" |
The engine rejected the image or options. |
"disposed" |
The scanner was disposed. |
"engine" |
Internal scanner failure. |
"capacity" |
Too many live scanners in this WASM instance. |
"abi_shape", "abi_version", "abi_mode", "abi_turbo" |
The loaded WASM file does not match this package or mode. |
"invalid_output" |
The engine returned unreadable output. |
"core_<n>" |
An unexpected engine status n. |
Loader and fetch errors reject Scanner.create and the one-shot helpers. Dispose
reusable scanners in finally.
License
Tapirscan is dual-licensed under MIT OR Apache-2.0, at your option. See the full license texts. Third-party components retain their own licenses and notices.