paulmillr/qr

Minimal 0-dep QR code generator & reader

TypeScript

371

175 commits

updated Sep 21, 2026

See the code

README

qr (paulmillr-qr)

Minimal 0-dependency QR code generator & reader.

  • πŸ”’ Auditable, 0-dependency
  • 🏎 Fast: up to 65x faster encoding/decoding than other JS libraries, faster than zxing-wasm
  • πŸ” High-quality: decodes 58% of BoofCV photo vectors
  • 🏞️ Encoding (generating) supports ASCII, term, gif, data-url and svg codes
  • πŸ“· Decoding (reading) supports camera feed input, files and non-browser environments
  • πŸͺΆ 6KB (gzipped) for encoding, 20KB for encoding + decoding

Check out Interactive demo; also there is cuer React Component.

Usage

npm install qr

jsr add jsr:@paulmillr/qr

To produce a standalone file, use bismar: npx bismar -b npm:qr/index.js/encodeQR.

Three entry points β€” import only what you use (sizes are esbuild-bundled, minified, gzipped):

importprovidessize
qrencodeQR5.4KB
qr/decode.jsdecodeQR, BarcodeDetector7.3KB (11.5KB with qr)
qr/dom.jsQRCanvas, rearCamera, selfieCamera, frameLoop, svgToPng, gifToPng9.3KB (includes decoder)

Encoding

import encodeQR from 'qr';

const txt = 'Hello world';
console.log(encodeQR(txt, 'term')); // print to terminal; works in every font
const svg = encodeQR(txt, 'svg'); // '<svg...' markup string for web pages
const gifBytes = encodeQR(txt, 'gif', { scale: 4 }); // Uint8Array, uncompressed GIF file
const dataUrl = encodeQR(txt, 'data-url', { scale: 4 }); // 'data:image/gif;base64,...' for <img src>
const matrix = encodeQR(txt, 'raw'); // boolean[][] for canvas or custom drawing
const ascii = encodeQR(txt, 'ascii'); // 2x denser than 'term', needs a box-drawing font

For PNG, render the SVG in a browser with svgToPng from qr/dom.js: const pngDataUrl = await svgToPng(encodeQR(txt, 'svg'), 512, 512);

All options:

type QrOpts = {
  ecc?: 'low' | 'medium' | 'quartile' | 'high'; // error correction: 7%, 15% (default), 25%, 30%
  encoding?: 'numeric' | 'alphanumeric' | 'byte'; // smallest fit is picked automatically
  textEncoder?: (text: string) => Uint8Array; // custom text-to-bytes encoder for 'byte' mode
  version?: number; // 1..40, QR code version; smallest fit is picked automatically
  mask?: number; // 0..7, mask number; best is picked automatically
  border?: number; // border (quiet zone) size in modules, default 2
  scale?: number; // pixels per module, default 1
  optimize?: boolean; // 'svg' only: merge modules into fewer path elements, default true
};
declare function encodeQR(text: string, output: 'raw', opts?: QrOpts): boolean[][];
declare function encodeQR(
  text: string,
  output: 'ascii' | 'term' | 'svg' | 'data-url',
  opts?: QrOpts
): string;
declare function encodeQR(text: string, output: 'gif', opts?: QrOpts): Uint8Array;

Example output:

β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
β–ˆβ–ˆ β–„β–„β–„β–„β–„ β–ˆ  β–„ β–„β–ˆβ–ˆβ–„ β–ˆβ–€β–€β–„β–ˆ β–„β–„β–„β–„β–„ β–ˆβ–ˆ
β–ˆβ–ˆ β–ˆ   β–ˆ β–ˆβ–„ β–„β–€β–ˆβ–ˆβ–ˆβ–„ β–ˆβ–ˆβ–ˆβ–„β–ˆ β–ˆ   β–ˆ β–ˆβ–ˆ
β–ˆβ–ˆ β–ˆβ–„β–„β–„β–ˆ β–ˆβ–„β–ˆ   β–ˆβ–„  β–„β–ˆβ–„β–ˆβ–ˆ β–ˆβ–„β–„β–„β–ˆ β–ˆβ–ˆ
β–ˆβ–ˆβ–„β–„β–„β–„β–„β–„β–„β–ˆβ–„β–ˆβ–„β–€ β–€β–„β–€ β–ˆβ–„β–€β–„β–ˆβ–„β–„β–„β–„β–„β–„β–„β–ˆβ–ˆ
β–ˆβ–ˆ β–€β–€  β–„β–„  β–„β–ˆβ–„β–€ β–ˆβ–ˆβ–€β–„β–€   β–ˆβ–€ β–ˆ  β–„β–ˆβ–ˆ
β–ˆβ–ˆβ–€β–„β–€β–€  β–„ β–„ β–€ β–€ β–ˆβ–„ β–€β–ˆβ–€β–€β–€β–€β–„ β–€β–„β–ˆβ–€β–ˆβ–ˆ
β–ˆβ–ˆβ–ˆ β–ˆβ–€β–€β–„β–„β–ˆ β–„β–€   β–€β–„  β–ˆβ–„β–ˆβ–„ β–€β–€β–€β–€β–€ β–ˆβ–ˆ
β–ˆβ–ˆ β–€  β–„ β–„β–„β–€β–ˆ β–ˆβ–€β–„β–ˆβ–€β–„β–ˆ β–„ β–ˆβ–ˆ  β–ˆ β–ˆ β–ˆβ–ˆ
β–ˆβ–ˆβ–€β–ˆβ–€β–„β–ˆ β–„β–ˆ β–ˆ β–„β–„β–€ β–ˆ β–€β–€β–ˆβ–€β–ˆβ–ˆβ–ˆβ–€β–„β–€β–€β–ˆβ–ˆβ–ˆ
β–ˆβ–ˆ  β–€β–€β–„ β–„β–„β–ˆ   β–„β–ˆβ–€β–„β–ˆβ–ˆβ–„ β–„β–„ β–€β–ˆ β–€β–ˆβ–„β–ˆβ–ˆ
β–ˆβ–ˆβ–„β–„β–„β–ˆβ–„β–„β–„β–ˆβ–€β–€β–„β–„β–€β–„ β–„β–„β–ˆβ–ˆβ–„ β–„β–„β–„  β–„β–„β–ˆβ–ˆβ–ˆ
β–ˆβ–ˆ β–„β–„β–„β–„β–„ β–ˆ β–„β–€β–€β–ˆβ–ˆ β–€β–ˆ β–„  β–ˆβ–„β–ˆ β–„β–ˆβ–ˆβ–€β–ˆβ–ˆ
β–ˆβ–ˆ β–ˆ   β–ˆ β–ˆ  β–„β–ˆβ–„ β–„β–ˆβ–ˆ  β–„β–„ β–„β–„β–„β–„β–ˆβ–€β–ˆβ–ˆβ–ˆ
β–ˆβ–ˆ β–ˆβ–„β–„β–„β–ˆ β–ˆβ–ˆ β–ˆβ–ˆ β–€ β–„β–€ β–€β–ˆβ–„ β–€  β–€ β–„ β–ˆβ–ˆ
β–ˆβ–ˆβ–„β–„β–„β–„β–„β–„β–„β–ˆβ–„β–„β–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–„β–ˆβ–ˆβ–„β–ˆβ–ˆβ–„β–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€

Decoding

decodeQR takes raw RGBA pixels β€” { width, height, data }, the shape a canvas ImageData already has β€” and returns the decoded string. It throws when no QR code is found: in a camera loop treat that as a frame miss and feed it the next frame instead of retrying the failed one.

Effort is controlled by two options. effort sets the retry tier: 1 keeps only mandatory work, larger values admit more hypotheses, Infinity runs every retry at every resolution up to the image's native one. timeLimit caps the milliseconds spent on optional retries and defaults to one 60-FPS frame budget, so live camera frames stay fast (a failing 1080p frame returns in ~4ms). For photos and file uploads pass { effort: Infinity, timeLimit: Infinity }, which lifts BoofCV accuracy from ~58% to ~61%. Successful decodes cost the same in every tier, since retries only run after a failed strict pass.

Scan from webcam

qr/dom.js ships the camera plumbing: rearCamera opens the rear (environment-facing) camera into a <video> element, QRCanvas decodes frames and optionally draws a live finder overlay and a preview of the decoded code, frameLoop wraps requestAnimationFrame:

import { QRCanvas, frameLoop, rearCamera } from 'qr/dom.js';

const video = document.querySelector('video');
const overlay = document.querySelector('canvas'); // positioned over the video; optional
const canvas = new QRCanvas({ overlay });
const camera = await rearCamera(video);
const cancel = frameLoop(() => {
  const decoded = camera.readFrame(canvas); // undefined until a frame decodes
  if (decoded !== undefined) {
    console.log(decoded);
    cancel();
    camera.stop(); // release the camera
  }
});

selfieCamera is the same helper for the user-facing camera (e.g. a code held up to a laptop webcam); camera.listDevices() and camera.setDevice(deviceId) switch cameras. Camera access requires a secure context: on iOS Safari that means https: β€” file: and plain http: won't work.

Decode image files

In browsers, the native image decoder handles any format:

import decodeQR from 'qr/decode.js';

async function decodeImageFile(file) {
  // file: e.g. from <input type="file"> or fetch(...).blob()
  const bitmap = await createImageBitmap(file);
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0);
  return decodeQR(ctx.getImageData(0, 0, bitmap.width, bitmap.height));
}

Outside browsers, decode image files to RGBA with any image library and pass { width, height, data } to decodeQR. Clean 1px-per-module rasters (the encoder's default scale: 1) are too small for run-length finder detection β€” upscale them β‰₯2Γ— first.

BarcodeDetector polyfill

qr/decode.js ships a BarcodeDetector ponyfill on top of decodeQR. Importing it has no side effects β€” use the class directly, or install it as a drop-in polyfill:

import { BarcodeDetector } from 'qr/decode.js';
globalThis.BarcodeDetector ??= BarcodeDetector;

const detector = new BarcodeDetector({ formats: ['qr_code'] });
const barcodes = await detector.detect(canvas); // [{ rawValue, boundingBox, cornerPoints, format }]

Differences from native implementations: only the qr_code format is supported, at most one barcode per image is returned, and cornerPoints reuse the decoder-projected symbol boundary, so they are estimates of the visible barcode edges.

Documentation

See docs/encode.md and docs/decode.md for more details.

Security

There are multiple ways a single text can be encoded in a QR code, which can lead to potential security implications:

  • Segmentation Differences: For example, abc123 can be encoded as: [{type: 'alphanum', data: 'abc'}, {type: 'num', data: '123'}] or [{type: 'alphanum', data: 'abc123'}]
  • Mask Selection Algorithms: Different libraries may use different algorithms for mask selection.
  • Default Settings: Variations in error correction levels and how many bits are stored before upgrading versions.

If an adversary can access multiple QR codes generated from a specific library, they may be able to fingerprint the user. This fingerprinting could be used to exfiltrate data from air-gapped systems. In such cases, the adversary would need to create a library-specific exploit.

We mitigate these risks by:

  • Cross-Testing: We currently cross-test against python-qrcode, which is closer to the specification than some JavaScript implementations.
  • Single Segment Encoding: We always use single-segment encoding. While this may not be the most optimal for performance, it reduces the amount of fingerprinting data.

TODO: Testing Against Multiple Libraries: To further improve security and reduce fingerprinting, we can cross-test against three to four popular libraries.

Speed

Measured 2026-08 on one idle AMD Zen 5 core, Node 24. Reproduce with npm run benchmark and npm run benchmark:thirdparty.

Encoding (raw matrix output, ECC medium), vs other JS encoders:

payloadqrqrcodelean-qruqrqrcode-generatornuintun
v1, 11 chars4.4Β΅s40Β΅s40Β΅s145Β΅s168Β΅s54Β΅s
v3, 31 chars7.5Β΅s90Β΅s95Β΅s302Β΅s430Β΅s150Β΅s
v8, 192 chars23Β΅s296Β΅s255Β΅s983Β΅s1.5ms681Β΅s
v18, 768 chars65Β΅s1.2ms977Β΅s3.3ms5.2ms2.5ms

Encoding + rendering ("-" means library doesn't support it):

formatqrqrcodelean-qruqrqrcode-generatornuintun
ascii4.6Β΅s41Β΅s44Β΅s148Β΅s176Β΅sβ€”
svg11Β΅s43Β΅s118Β΅s153Β΅s166Β΅sβ€”
data-url5.5Β΅s537Β΅s54Β΅sβ€”194Β΅s220Β΅s

Decoding, vs JS/WASM alternatives, over 20 different photos:

workloadqrzxing-wasm@zxing/libraryzbar-wasmjsqr
raster v1108Β΅s86Β΅s62Β΅s254Β΅s417Β΅s
720p ok2.4ms2.4ms3.6ms16.4ms23.8ms
1080p ok5.3ms8.8ms11ms63ms351ms
12MP ok25.1ms42ms54ms260ms528ms

Quality

We're measuring accuracy / quality via BoofCV test vectors.

Measured with npm run benchmark:quality on 2026-08:

  • qr (this library): 57.5% default, 60.6% with effort: Infinity, timeLimit: Infinity
  • zxing-cpp fast mode 50%, slow mode 75%

Not all of the test vectors are equal for webcam decoding:

categorywhy it matters on a webcam
blurredP1: focus-hunting is the dominant webcam failure mode
bright_spotsP2: LED/spotlight hotspots; overlaps glare, rarer
brightnessP1: webcam auto-exposure constantly over/undershoots
closeP2: codes shoved inside autofocus range β€” huge blurry modules
curvedP2: codes on bottles/packaging held to the camera
damagedP2: worn prints exist, but webcam scans skew toward screens/fresh prints
glareP1: specular reflections off glossy prints, phone screens, lamination
high_versionP3: dense v20+ rarely resolves at 720p regardless of decoder
lotsP3: many codes on one sheet β€” a webcam session aims at one code
monitorP1: codes shown on screens (login/pairing/payment) β€” the top desktop use
nominalP1: the baseline "code held decently in view" case
noncompliantP3: spec-violating codes are rare in the wild
pathologicalP3: synthetic stress patterns, not camera reality
perspectiveP1: nobody holds paper parallel to the lens
rotationsP1: hand-held codes arrive at arbitrary orientation
shadowsP1: the user's own hand/head shadows the held-up code

License

Copyright (c) 2023 Paul Miller (paulmillr.com)

Copyright (c) 2019 ZXing authors

The library paulmillr-qr is dual-licensed under the Apache 2.0 OR MIT license. You can select a license of your choice.

The library contains code inspired by ZXing, which is licensed under Apache 2.0.

The license to the use of the QR Code stipulated by JIS (Japanese Industrial Standards) and the ISO are not necessary. The specification for QR Code has been made available for use by any person or organization. (Obtaining QR Code Specification) The word β€œQR Code” is registered trademark of DENSO WAVE INCORPORATED in Japan and other countries. To use the word β€œQR Code” in your publications or web site, etc, please indicate a sentence QR Code is registered trademark of DENSO WAVE INCORPORATED. This registered trademark applies only for the word β€œQR Code”, and not for the QR Code pattern (image). (https://www.qrcode.com/en/faq.html)

qr
qr-code
qrcode
qrcode-generator
qrcode-reader
qrcode-scanner

Contributors

paulmillr

115 commits

shreeve

38 commits

maia-yeager

11 commits

Pjb518

3 commits

paulmillr/qr

Minimal 0-dep QR code generator & reader

TypeScript

371

175 commits

updated Sep 21, 2026

See the code

README

qr (paulmillr-qr)

Minimal 0-dependency QR code generator & reader.

  • πŸ”’ Auditable, 0-dependency
  • 🏎 Fast: up to 65x faster encoding/decoding than other JS libraries, faster than zxing-wasm
  • πŸ” High-quality: decodes 58% of BoofCV photo vectors
  • 🏞️ Encoding (generating) supports ASCII, term, gif, data-url and svg codes
  • πŸ“· Decoding (reading) supports camera feed input, files and non-browser environments
  • πŸͺΆ 6KB (gzipped) for encoding, 20KB for encoding + decoding

Check out Interactive demo; also there is cuer React Component.

Usage

npm install qr

jsr add jsr:@paulmillr/qr

To produce a standalone file, use bismar: npx bismar -b npm:qr/index.js/encodeQR.

Three entry points β€” import only what you use (sizes are esbuild-bundled, minified, gzipped):

importprovidessize
qrencodeQR5.4KB
qr/decode.jsdecodeQR, BarcodeDetector7.3KB (11.5KB with qr)
qr/dom.jsQRCanvas, rearCamera, selfieCamera, frameLoop, svgToPng, gifToPng9.3KB (includes decoder)

Encoding

import encodeQR from 'qr';

const txt = 'Hello world';
console.log(encodeQR(txt, 'term')); // print to terminal; works in every font
const svg = encodeQR(txt, 'svg'); // '<svg...' markup string for web pages
const gifBytes = encodeQR(txt, 'gif', { scale: 4 }); // Uint8Array, uncompressed GIF file
const dataUrl = encodeQR(txt, 'data-url', { scale: 4 }); // 'data:image/gif;base64,...' for <img src>
const matrix = encodeQR(txt, 'raw'); // boolean[][] for canvas or custom drawing
const ascii = encodeQR(txt, 'ascii'); // 2x denser than 'term', needs a box-drawing font

For PNG, render the SVG in a browser with svgToPng from qr/dom.js: const pngDataUrl = await svgToPng(encodeQR(txt, 'svg'), 512, 512);

All options:

type QrOpts = {
  ecc?: 'low' | 'medium' | 'quartile' | 'high'; // error correction: 7%, 15% (default), 25%, 30%
  encoding?: 'numeric' | 'alphanumeric' | 'byte'; // smallest fit is picked automatically
  textEncoder?: (text: string) => Uint8Array; // custom text-to-bytes encoder for 'byte' mode
  version?: number; // 1..40, QR code version; smallest fit is picked automatically
  mask?: number; // 0..7, mask number; best is picked automatically
  border?: number; // border (quiet zone) size in modules, default 2
  scale?: number; // pixels per module, default 1
  optimize?: boolean; // 'svg' only: merge modules into fewer path elements, default true
};
declare function encodeQR(text: string, output: 'raw', opts?: QrOpts): boolean[][];
declare function encodeQR(
  text: string,
  output: 'ascii' | 'term' | 'svg' | 'data-url',
  opts?: QrOpts
): string;
declare function encodeQR(text: string, output: 'gif', opts?: QrOpts): Uint8Array;

Example output:

β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
β–ˆβ–ˆ β–„β–„β–„β–„β–„ β–ˆ  β–„ β–„β–ˆβ–ˆβ–„ β–ˆβ–€β–€β–„β–ˆ β–„β–„β–„β–„β–„ β–ˆβ–ˆ
β–ˆβ–ˆ β–ˆ   β–ˆ β–ˆβ–„ β–„β–€β–ˆβ–ˆβ–ˆβ–„ β–ˆβ–ˆβ–ˆβ–„β–ˆ β–ˆ   β–ˆ β–ˆβ–ˆ
β–ˆβ–ˆ β–ˆβ–„β–„β–„β–ˆ β–ˆβ–„β–ˆ   β–ˆβ–„  β–„β–ˆβ–„β–ˆβ–ˆ β–ˆβ–„β–„β–„β–ˆ β–ˆβ–ˆ
β–ˆβ–ˆβ–„β–„β–„β–„β–„β–„β–„β–ˆβ–„β–ˆβ–„β–€ β–€β–„β–€ β–ˆβ–„β–€β–„β–ˆβ–„β–„β–„β–„β–„β–„β–„β–ˆβ–ˆ
β–ˆβ–ˆ β–€β–€  β–„β–„  β–„β–ˆβ–„β–€ β–ˆβ–ˆβ–€β–„β–€   β–ˆβ–€ β–ˆ  β–„β–ˆβ–ˆ
β–ˆβ–ˆβ–€β–„β–€β–€  β–„ β–„ β–€ β–€ β–ˆβ–„ β–€β–ˆβ–€β–€β–€β–€β–„ β–€β–„β–ˆβ–€β–ˆβ–ˆ
β–ˆβ–ˆβ–ˆ β–ˆβ–€β–€β–„β–„β–ˆ β–„β–€   β–€β–„  β–ˆβ–„β–ˆβ–„ β–€β–€β–€β–€β–€ β–ˆβ–ˆ
β–ˆβ–ˆ β–€  β–„ β–„β–„β–€β–ˆ β–ˆβ–€β–„β–ˆβ–€β–„β–ˆ β–„ β–ˆβ–ˆ  β–ˆ β–ˆ β–ˆβ–ˆ
β–ˆβ–ˆβ–€β–ˆβ–€β–„β–ˆ β–„β–ˆ β–ˆ β–„β–„β–€ β–ˆ β–€β–€β–ˆβ–€β–ˆβ–ˆβ–ˆβ–€β–„β–€β–€β–ˆβ–ˆβ–ˆ
β–ˆβ–ˆ  β–€β–€β–„ β–„β–„β–ˆ   β–„β–ˆβ–€β–„β–ˆβ–ˆβ–„ β–„β–„ β–€β–ˆ β–€β–ˆβ–„β–ˆβ–ˆ
β–ˆβ–ˆβ–„β–„β–„β–ˆβ–„β–„β–„β–ˆβ–€β–€β–„β–„β–€β–„ β–„β–„β–ˆβ–ˆβ–„ β–„β–„β–„  β–„β–„β–ˆβ–ˆβ–ˆ
β–ˆβ–ˆ β–„β–„β–„β–„β–„ β–ˆ β–„β–€β–€β–ˆβ–ˆ β–€β–ˆ β–„  β–ˆβ–„β–ˆ β–„β–ˆβ–ˆβ–€β–ˆβ–ˆ
β–ˆβ–ˆ β–ˆ   β–ˆ β–ˆ  β–„β–ˆβ–„ β–„β–ˆβ–ˆ  β–„β–„ β–„β–„β–„β–„β–ˆβ–€β–ˆβ–ˆβ–ˆ
β–ˆβ–ˆ β–ˆβ–„β–„β–„β–ˆ β–ˆβ–ˆ β–ˆβ–ˆ β–€ β–„β–€ β–€β–ˆβ–„ β–€  β–€ β–„ β–ˆβ–ˆ
β–ˆβ–ˆβ–„β–„β–„β–„β–„β–„β–„β–ˆβ–„β–„β–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–„β–ˆβ–ˆβ–„β–ˆβ–ˆβ–„β–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€

Decoding

decodeQR takes raw RGBA pixels β€” { width, height, data }, the shape a canvas ImageData already has β€” and returns the decoded string. It throws when no QR code is found: in a camera loop treat that as a frame miss and feed it the next frame instead of retrying the failed one.

Effort is controlled by two options. effort sets the retry tier: 1 keeps only mandatory work, larger values admit more hypotheses, Infinity runs every retry at every resolution up to the image's native one. timeLimit caps the milliseconds spent on optional retries and defaults to one 60-FPS frame budget, so live camera frames stay fast (a failing 1080p frame returns in ~4ms). For photos and file uploads pass { effort: Infinity, timeLimit: Infinity }, which lifts BoofCV accuracy from ~58% to ~61%. Successful decodes cost the same in every tier, since retries only run after a failed strict pass.

Scan from webcam

qr/dom.js ships the camera plumbing: rearCamera opens the rear (environment-facing) camera into a <video> element, QRCanvas decodes frames and optionally draws a live finder overlay and a preview of the decoded code, frameLoop wraps requestAnimationFrame:

import { QRCanvas, frameLoop, rearCamera } from 'qr/dom.js';

const video = document.querySelector('video');
const overlay = document.querySelector('canvas'); // positioned over the video; optional
const canvas = new QRCanvas({ overlay });
const camera = await rearCamera(video);
const cancel = frameLoop(() => {
  const decoded = camera.readFrame(canvas); // undefined until a frame decodes
  if (decoded !== undefined) {
    console.log(decoded);
    cancel();
    camera.stop(); // release the camera
  }
});

selfieCamera is the same helper for the user-facing camera (e.g. a code held up to a laptop webcam); camera.listDevices() and camera.setDevice(deviceId) switch cameras. Camera access requires a secure context: on iOS Safari that means https: β€” file: and plain http: won't work.

Decode image files

In browsers, the native image decoder handles any format:

import decodeQR from 'qr/decode.js';

async function decodeImageFile(file) {
  // file: e.g. from <input type="file"> or fetch(...).blob()
  const bitmap = await createImageBitmap(file);
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0);
  return decodeQR(ctx.getImageData(0, 0, bitmap.width, bitmap.height));
}

Outside browsers, decode image files to RGBA with any image library and pass { width, height, data } to decodeQR. Clean 1px-per-module rasters (the encoder's default scale: 1) are too small for run-length finder detection β€” upscale them β‰₯2Γ— first.

BarcodeDetector polyfill

qr/decode.js ships a BarcodeDetector ponyfill on top of decodeQR. Importing it has no side effects β€” use the class directly, or install it as a drop-in polyfill:

import { BarcodeDetector } from 'qr/decode.js';
globalThis.BarcodeDetector ??= BarcodeDetector;

const detector = new BarcodeDetector({ formats: ['qr_code'] });
const barcodes = await detector.detect(canvas); // [{ rawValue, boundingBox, cornerPoints, format }]

Differences from native implementations: only the qr_code format is supported, at most one barcode per image is returned, and cornerPoints reuse the decoder-projected symbol boundary, so they are estimates of the visible barcode edges.

Documentation

See docs/encode.md and docs/decode.md for more details.

Security

There are multiple ways a single text can be encoded in a QR code, which can lead to potential security implications:

  • Segmentation Differences: For example, abc123 can be encoded as: [{type: 'alphanum', data: 'abc'}, {type: 'num', data: '123'}] or [{type: 'alphanum', data: 'abc123'}]
  • Mask Selection Algorithms: Different libraries may use different algorithms for mask selection.
  • Default Settings: Variations in error correction levels and how many bits are stored before upgrading versions.

If an adversary can access multiple QR codes generated from a specific library, they may be able to fingerprint the user. This fingerprinting could be used to exfiltrate data from air-gapped systems. In such cases, the adversary would need to create a library-specific exploit.

We mitigate these risks by:

  • Cross-Testing: We currently cross-test against python-qrcode, which is closer to the specification than some JavaScript implementations.
  • Single Segment Encoding: We always use single-segment encoding. While this may not be the most optimal for performance, it reduces the amount of fingerprinting data.

TODO: Testing Against Multiple Libraries: To further improve security and reduce fingerprinting, we can cross-test against three to four popular libraries.

Speed

Measured 2026-08 on one idle AMD Zen 5 core, Node 24. Reproduce with npm run benchmark and npm run benchmark:thirdparty.

Encoding (raw matrix output, ECC medium), vs other JS encoders:

payloadqrqrcodelean-qruqrqrcode-generatornuintun
v1, 11 chars4.4Β΅s40Β΅s40Β΅s145Β΅s168Β΅s54Β΅s
v3, 31 chars7.5Β΅s90Β΅s95Β΅s302Β΅s430Β΅s150Β΅s
v8, 192 chars23Β΅s296Β΅s255Β΅s983Β΅s1.5ms681Β΅s
v18, 768 chars65Β΅s1.2ms977Β΅s3.3ms5.2ms2.5ms

Encoding + rendering ("-" means library doesn't support it):

formatqrqrcodelean-qruqrqrcode-generatornuintun
ascii4.6Β΅s41Β΅s44Β΅s148Β΅s176Β΅sβ€”
svg11Β΅s43Β΅s118Β΅s153Β΅s166Β΅sβ€”
data-url5.5Β΅s537Β΅s54Β΅sβ€”194Β΅s220Β΅s

Decoding, vs JS/WASM alternatives, over 20 different photos:

workloadqrzxing-wasm@zxing/libraryzbar-wasmjsqr
raster v1108Β΅s86Β΅s62Β΅s254Β΅s417Β΅s
720p ok2.4ms2.4ms3.6ms16.4ms23.8ms
1080p ok5.3ms8.8ms11ms63ms351ms
12MP ok25.1ms42ms54ms260ms528ms

Quality

We're measuring accuracy / quality via BoofCV test vectors.

Measured with npm run benchmark:quality on 2026-08:

  • qr (this library): 57.5% default, 60.6% with effort: Infinity, timeLimit: Infinity
  • zxing-cpp fast mode 50%, slow mode 75%

Not all of the test vectors are equal for webcam decoding:

categorywhy it matters on a webcam
blurredP1: focus-hunting is the dominant webcam failure mode
bright_spotsP2: LED/spotlight hotspots; overlaps glare, rarer
brightnessP1: webcam auto-exposure constantly over/undershoots
closeP2: codes shoved inside autofocus range β€” huge blurry modules
curvedP2: codes on bottles/packaging held to the camera
damagedP2: worn prints exist, but webcam scans skew toward screens/fresh prints
glareP1: specular reflections off glossy prints, phone screens, lamination
high_versionP3: dense v20+ rarely resolves at 720p regardless of decoder
lotsP3: many codes on one sheet β€” a webcam session aims at one code
monitorP1: codes shown on screens (login/pairing/payment) β€” the top desktop use
nominalP1: the baseline "code held decently in view" case
noncompliantP3: spec-violating codes are rare in the wild
pathologicalP3: synthetic stress patterns, not camera reality
perspectiveP1: nobody holds paper parallel to the lens
rotationsP1: hand-held codes arrive at arbitrary orientation
shadowsP1: the user's own hand/head shadows the held-up code

License

Copyright (c) 2023 Paul Miller (paulmillr.com)

Copyright (c) 2019 ZXing authors

The library paulmillr-qr is dual-licensed under the Apache 2.0 OR MIT license. You can select a license of your choice.

The library contains code inspired by ZXing, which is licensed under Apache 2.0.

The license to the use of the QR Code stipulated by JIS (Japanese Industrial Standards) and the ISO are not necessary. The specification for QR Code has been made available for use by any person or organization. (Obtaining QR Code Specification) The word β€œQR Code” is registered trademark of DENSO WAVE INCORPORATED in Japan and other countries. To use the word β€œQR Code” in your publications or web site, etc, please indicate a sentence QR Code is registered trademark of DENSO WAVE INCORPORATED. This registered trademark applies only for the word β€œQR Code”, and not for the QR Code pattern (image). (https://www.qrcode.com/en/faq.html)

qr
qr-code
qrcode
qrcode-generator
qrcode-reader
qrcode-scanner

Contributors

paulmillr

115 commits

shreeve

38 commits

maia-yeager

11 commits

Pjb518

3 commits

Languages

TypeScript

100.0%