WebUSB and Web Serial Receipt Printing
Printing to thermal printers straight from Chrome with WebUSB/Web Serial: working code, browser support reality, and where the approach breaks down.
Published 2026-07-24 · For developers →
WebUSB and Web Serial let a page in Chrome or Edge send ESC/POS bytes straight to a thermal printer — no driver package, no installed agent, no server. It genuinely works, and for a kiosk you control it can be the simplest possible stack. It also comes with hard limits — Chromium-only, HTTPS plus a user gesture, and a Windows driver swap that breaks other apps' printing — so this guide covers both the working code and the fine print.
How WebUSB printing works
Four steps: ask the user to pick a device, open and claim it, find the bulk OUT endpoint, and write bytes to it.
1. Request the device
navigator.usb.requestDevice() must run inside a user gesture (a click handler) on a secure context (https:// or localhost). The filters decide what appears in Chrome's picker:
const device = await navigator.usb.requestDevice({
filters: [
{ classCode: 7 }, // any USB printer-class device
{ vendorId: 0x04b8 }, // Epson
{ vendorId: 0x0519 }, // Star Micronics
],
});classCode: 7 is the USB printer class and catches most thermal printers regardless of brand. Vendor-ID filters are useful when you want the picker to show only hardware you support. The permission sticks per origin and device, so the picker appears once; on later visits navigator.usb.getDevices() returns already-authorized printers with no dialog.
2. Open, configure, claim
await device.open();
if (device.configuration === null) {
await device.selectConfiguration(1);
}
// Find the printer-class interface rather than hardcoding 0.
const iface = device.configuration.interfaces.find(
(i) => i.alternate.interfaceClass === 7,
);
await device.claimInterface(iface.interfaceNumber);Claiming is exclusive: if the operating system's printer driver already owns the interface, claimInterface() rejects. That single fact is the root of most WebUSB-printing pain on Windows (more below).
3. Find the OUT endpoint and write
Don't hardcode endpoint numbers — enumerate them:
const endpointOut = iface.alternate.endpoints.find(
(e) => e.direction === "out" && e.type === "bulk",
);
async function print(bytes) {
const result = await device.transferOut(endpointOut.endpointNumber, bytes);
if (result.status !== "ok") throw new Error(`transfer ${result.status}`);
}4. Build the ESC/POS payload
The printer wants raw command bytes, not text alone. A minimal receipt:
const enc = new TextEncoder();
const receipt = new Uint8Array([
0x1b, 0x40, // ESC @ initialize
0x1b, 0x61, 0x01, // ESC a 1 center
...enc.encode("ORDER #42\n"),
0x1b, 0x61, 0x00, // ESC a 0 left
...enc.encode("1x Flat White 4.50\n"),
...enc.encode("1x Croissant 3.75\n"),
0x1b, 0x64, 0x05, // ESC d 5 feed 5 lines
0x1d, 0x56, 0x00, // GS V 0 full cut
]);
await print(receipt);The feed before the cut matters: the print head sits above the cutter, so without it the blade slices through your last lines. For the wider command set — styles, barcodes, the drawer kick — see the ESC/POS command reference.
The Web Serial equivalent
Web Serial covers printers on RS-232 or on USB-to-serial controllers (common in cheap thermal printers, which often enumerate as a CH340 or Prolific serial chip rather than a printer-class device). The API is simpler than WebUSB and, on Windows, far less painful — serial devices keep their normal OS driver.
const port = await navigator.serial.requestPort(); // user gesture required
await port.open({ baudRate: 19200 }); // printer-dependent: 9600/19200/38400
const writer = port.writable.getWriter();
await writer.write(receipt); // same Uint8Array as above
writer.releaseLock();The baud rate must match the printer's configuration (check its self-test page — hold the feed button while powering on). A mismatched rate prints garbage, which is also your first debugging clue.
Reading is symmetric — port.readable.getReader() — which matters if you want DLE EOT status replies back from the printer.
The reality section
Everything above works. Here is what the tutorials skip.
Browser support: Chromium, full stop
| Browser | WebUSB | Web Serial |
|---|---|---|
| Chrome / Edge / Chromium (desktop) | yes | yes |
| Chrome on Android | yes | no |
| Firefox | no — Mozilla's standards position is negative | no |
| Safari (macOS and iOS) | no — WebKit has declined the API | no |
No Safari means no iPads — which rules out a large share of real POS front-ends before any other consideration. If your product must run in Safari or Firefox, stop here: you need an agent like QZ Tray or a network hop instead. See printing from a web app for that full decision tree.
HTTPS and the gesture requirement
Both APIs require a secure context and a user gesture for the initial picker. In practice this is workable for POS — a "connect printer" button in settings, once per station — but it means printing can never be silently set up from code alone, and http:// intranet pages are excluded (though localhost counts as secure).
Windows: the WinUSB driver swap
This is the sharpest edge. On Windows, printer-class USB devices are claimed by usbprint.sys the moment they're plugged in — and while the OS driver holds the interface, Chrome's claimInterface() fails with an access error. The standard workaround is replacing the device's driver with WinUSB using Zadig. That works, but:
- Every other application loses the printer — the OS print queue, the vendor utility, your report printing. The device is now exclusively a WebUSB peripheral.
- The swap is per-machine, per-device manual surgery, exactly the kind of installation step WebUSB was supposed to remove.
- A Windows update or a different USB port can re-trigger driver installation.
macOS and Linux are gentler (Linux may need a udev rule so the browser can open the device without root). If your fleet is Windows, weigh this cost honestly — for many teams it cancels the "no install" benefit.
One claimer at a time
A USB interface can be claimed by exactly one thing. Two open tabs, a PWA and a browser window, or your app plus a background service will fight over the printer. Design for a single printing surface.
Status is often write-only
Bidirectional transfer needs the printer to expose a bulk IN endpoint — and to actually answer on it. Many budget printers (and some USB controller chips) are effectively write-only over USB: your transferIn() just times out. When the printer does answer, you can run the real-time status conversation:
// DLE EOT 4 — ask for paper status
await device.transferOut(endpointOut.endpointNumber, new Uint8Array([0x10, 0x04, 0x04]));
const reply = await device.transferIn(endpointIn.endpointNumber, 1);
const byte = reply.data.getUint8(0);
if ((byte & 0x60) !== 0) console.log("paper out");
else if ((byte & 0x0c) !== 0) console.log("paper near end");The byte layout, the other three queries, and the many ways printers lie about status are covered in the DLE EOT deep dive. The design rule: treat "no reply" as unknown, never as "printer OK".
When WebUSB/Web Serial is the right tool
Reach for it when:
- You run a single-station kiosk or PWA on hardware and browsers you control (Chrome kiosk mode, managed Chromebooks, a dedicated Android tablet).
- The printer is dedicated to the web app — nothing else on the machine needs it.
- You want zero installed software and can live within Chromium.
- Offline-first matters: the whole path is local, so it prints with the internet down.
Reach for something else when:
- You need Safari or Firefox — an agent (QZ Tray) or a LAN print API are your options; compare them in print from a web app.
- Multiple terminals share one printer. USB is one-cable, one-claimer; a shared printer wants to be on the network. See the receipt printer API guide for network-side patterns.
- Your fleet is Windows with mixed printing needs — the WinUSB swap will break the other needs.
- You need trustworthy status and job feedback across cheap hardware. A network device that owns the printer connection can poll status continuously and report honestly; a browser tab that may be closed cannot.
A practical middle path: keep your ESC/POS generation in the browser, and make the transport swappable — transferOut() today, a fetch() to a LAN print API tomorrow. The bytes are identical; only the last meter changes. You can test that byte stream today without any printer against the digital twin simulator, then point the same code at hardware. More patterns on the developers hub.
Frequently asked questions
- Does WebUSB receipt printing work in Safari or Firefox?
- No. WebUSB and Web Serial are Chromium-only: Chrome, Edge, and other Chromium browsers on desktop, plus WebUSB on Chrome for Android. WebKit and Mozilla have both declined the APIs, so Safari (including all iPads) and Firefox are out.
- Why does claimInterface() fail with a security or access error?
- The operating system's printer driver already owns the interface — on Windows that's usbprint.sys. You must detach the OS driver (WinUSB via Zadig on Windows, a udev rule on Linux), after which other applications can no longer use the printer.
- Can I print without showing the device picker every time?
- Yes. The picker is needed once per origin and device. Afterwards navigator.usb.getDevices() (or navigator.serial.getPorts()) returns authorized devices silently, so a kiosk reconnects on page load without any dialog.
- Should I use WebUSB or Web Serial for my thermal printer?
- Match the hardware. Printer-class USB devices (most Epson/Star) are WebUSB; cheap printers with CH340/Prolific USB-serial chips, and anything on a real RS-232 port, are Web Serial. Web Serial also avoids the Windows driver swap because serial drivers stay in place.
- Can I read paper-out status over WebUSB?
- Only if the printer exposes a bulk IN endpoint and implements DLE EOT over USB — many budget models are write-only. Send 10 04 04 and read one byte back; if nothing comes back, report status as unknown rather than assuming the printer is fine.
Related reading
- Print to a Receipt Printer from a Web AppFour working ways to print receipts from the browser — QZ Tray, WebUSB, cloud relays, and a LAN print API — with code and honest trade-offs.
- Receipt Printer API: the Complete GuideEvery way software prints receipts in 2026 — ESC/POS, port 9100, vendor SDKs, cloud relays — and how a local HTTP API on the LAN compares.
- ESC/POS Commands: a Practical ReferenceThe ESC/POS commands that matter in production — init, text style, feed, cut, drawer kick, status — with raw bytes and the quirks between brands.