Print to a Receipt Printer from a Web App
Four working ways to print receipts from the browser — QZ Tray, WebUSB, cloud relays, and a LAN print API — with code and honest trade-offs.
Published 2026-07-24 · For developers →
Browsers cannot open a TCP socket, claim a USB endpoint, or touch a serial port from ordinary JavaScript — so there is no direct way to send receipt-printer bytes from a web page. Every working setup routes the job through something else: a local agent (QZ Tray), a browser hardware API (WebUSB/Web Serial), a cloud relay (PrintNode), or a print API on the LAN. This guide shows working code for all four, with the trade-offs printed on the label.
Why window.print() fails for receipts
window.print() renders your page as a document through the operating system's print pipeline. That is the wrong tool for a receipt in at least five ways:
- It prints HTML, not ESC/POS. Receipt printers are commanded with byte sequences —
1B 40to initialize,1D 56 00to cut. The OS driver rasterizes your CSS instead, usually onto an A4/Letter page model that a 80 mm roll printer mangles. - The dialog needs a click. Browsers will not silently print. A cashier confirming a print dialog on every order is a non-starter; the workarounds (Chrome's
--kiosk-printingflag) mean you control the machine anyway. - No cutter, no drawer. Paper cut and the cash-drawer kick pulse are ESC/POS commands. A driver-rendered page can't send them.
- No status. You get no signal for paper-out, cover-open, or offline. The job "succeeds" into a void.
- A driver per workstation. You're back to installing and babysitting printer drivers — the thing a web app was supposed to avoid.
So the real question is: which piece of software turns your HTTP-world request into printer-world bytes, and where does it run? There are four honest answers.
Option 1: QZ Tray — a local WebSocket agent
QZ Tray is a Java application installed on each workstation. Your page connects to it over a local WebSocket, and the agent forwards raw bytes to any printer the OS can see. It is the established answer to this problem and it works on Chrome, Firefox, Safari, and Edge, because the browser only ever talks to localhost.
<script src="qz-tray.js"></script>await qz.websocket.connect();
const config = qz.configs.create("EPSON TM-T88V Receipt"); // OS printer name
const data = [{
type: "raw",
format: "command",
flavor: "plain",
data:
"\x1B\x40" + // ESC @ initialize
"ORDER #42\x0A" +
"1x Flat White 4.50\x0A" +
"\x1B\x64\x04" + // ESC d 4 feed 4 lines
"\x1D\x56\x00", // GS V 0 full cut
}];
await qz.print(config, data);The catch is the trust model. QZ Tray verifies a digital signature on every print request; without one, users see a warning dialog they must approve. To make printing silent you either buy a signing certificate from QZ — bundled with support plans at $599/yr (Premium) up to $2,999/yr (white-label) — or generate your own certificate and install your root on every workstation you deploy to. Either way you own a Java install, auto-update policy, and certificate lifecycle per machine.
Good fit: multi-browser support requirements, existing OS-installed printers, teams that accept the per-workstation agent. Weak fit: zero-install ambitions, tablets, or anyone allergic to certificate plumbing.
Option 2: WebUSB and Web Serial — no agent at all
Chromium browsers can talk to USB and serial devices directly, which means a page can send ESC/POS bytes to a thermal printer with no installed software at all:
const device = await navigator.usb.requestDevice({
filters: [{ classCode: 7 }], // USB printer class
});
await device.open();
await device.selectConfiguration(1);
await device.claimInterface(0);
const bytes = new TextEncoder().encode("\x1B\x40ORDER #42\n\n\n\n\x1D\x56\x00");
await device.transferOut(1, bytes);It genuinely works — on the right stack. The constraints are sharp: Chrome/Edge only (no Safari, no Firefox), HTTPS plus a user gesture for the device picker, and on Windows you typically have to replace the printer's driver with WinUSB before the browser may claim it, which breaks printing from every other application on that machine. The full ceremony — interface discovery, Web Serial, the Windows driver swap, status reads — is covered in our deep dive: WebUSB and Web Serial receipt printing.
Good fit: single-station kiosks and PWAs where you control the hardware and the browser. Weak fit: anything multi-terminal, anything on iPads, shared printers.
Option 3: A cloud print relay (PrintNode)
Cloud relays invert the problem: your server posts the job to their API, and a desktop client they provide — running on a PC, Mac, or Raspberry Pi at the shop — polls for jobs and prints them locally.
// Server-side (Node). Never call this from the browser —
// the API key must not ship to the client.
const receipt = "\x1B\x40ORDER #42\n\n\n\n\x1D\x56\x00";
const res = await fetch("https://api.printnode.com/printjobs", {
method: "POST",
headers: {
Authorization: "Basic " + Buffer.from(API_KEY + ":").toString("base64"),
"Content-Type": "application/json",
},
body: JSON.stringify({
printerId: 123456, // from GET /printers
title: "Order #42",
contentType: "raw_base64",
content: Buffer.from(receipt, "binary").toString("base64"),
}),
});PrintNode's pricing starts free at 50 prints/month, then $9/mo (5,000 prints), $29/mo (25,000), $99/mo (200,000), with integrator plans at $60–$500/mo. Two structural costs come with the architecture no matter the tier. First, every print is metered and makes a round trip through the internet, so a busy location doing 1,000+ tickets a day feels both the latency and the bill. Second, printing dies when the uplink dies — for e-commerce packing slips that's survivable, but a kitchen that can't print tickets during an outage is a stopped kitchen. And you still need a full computer at each site running the client.
Good fit: e-commerce back offices, low-volume remote printing, teams that want zero networking work. Weak fit: front-of-house POS, kitchen tickets, anywhere offline operation matters.
Option 4: A print API on the LAN
The fourth pattern moves the byte-translation onto the local network: some device or service near the printer exposes an HTTP API, and your web app calls it with fetch(). No per-workstation agent, no cloud round trip, and the receipt still prints when the internet is down. The service can be a Raspberry Pi running python-escpos behind Flask, a vendor "intelligent printer" web service, or a dedicated node.
A ProxyNode is that pattern reduced to a ~$10 device: it sits next to the printer and serves an open HTTP/JSON API on the LAN. The browser posts a small document model; the node renders it to ESC/POS and talks USB to the printer:
await fetch("http://proxynode-a1b2c3.local/print", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "print",
id: crypto.randomUUID(),
endpoint: "receipt",
lines: [
{ type: "text", value: "ORDER #42", bold: true, align: "center" },
{ type: "rule" },
{ type: "text", value: "1x Flat White 4.50" },
{ type: "feed", lines: 2 },
{ type: "cut" },
],
}),
});The response tells you whether the job was accepted, and GET /status reports paper, cover, and drawer state — with known: false when the printer genuinely can't be read, instead of a guessed "everything's fine". You can run the same API today without hardware: the digital twin simulator is a LAN service that behaves byte-for-byte like a node, fault injection included.
The CORS and mixed-content reality
Calling LAN devices from a page has two browser-security gotchas, and honest guides mention them:
- CORS. A cross-origin
fetch()only succeeds if the LAN service answers preflights with the rightAccess-Control-Allow-Originheaders. Whatever you deploy on the LAN must speak CORS, or your app must be served from an origin the device trusts. - Mixed content. A page served over
https://cannot fetch plainhttp://LAN URLs; browsers block it. Chrome is additionally rolling out Private Network Access checks, which add a permission gate for public-to-private requests. The realistic deployment patterns are: serve the POS front-end from a local origin, relay through an on-site backend, or control the browser (kiosk policies) in dedicated installs.
None of this is unique to any product — it applies equally to a Pi, an Epson intelligent printer, or a node. Plan for it on day one.
The four options, honestly compared
| QZ Tray | WebUSB / Web Serial | Cloud relay (PrintNode) | LAN print API | |
|---|---|---|---|---|
| Install per workstation | Java agent + certificate | none (browser only) | none (client per site) | none |
| Extra hardware per site | no | no | PC/Mac/Pi running client | the LAN device/node |
| Works with internet down | yes | yes | no | yes |
| Browser support | all major | Chrome/Edge only | all (server-side call) | all major |
| Printer status (paper/cover) | limited, driver-dependent | possible, often write-only | job-state only | yes, as JSON |
| Recurring cost | $0–2,999/yr (certificates/support) | $0 | $9–500/mo, metered per print | $0 software; device cost |
| Sharpest edge | cert + agent lifecycle | Windows driver swap, no Safari/Firefox | offline = no printing | CORS/mixed-content setup |
Which one should you pick?
- You need Safari/Firefox and printers already installed on the OS: QZ Tray. Budget for certificates and agent management.
- Single kiosk, you own the machine, Chrome only: WebUSB or Web Serial. Read the deep dive before committing — the Windows driver swap surprises people.
- Low-volume, remote, back-office printing: a cloud relay is the least engineering. Accept the metering and the internet dependency.
- POS, kitchen, or anything that must print during an outage: put the printing on the LAN. That can be a Pi you maintain, a pricier "intelligent" printer, or a node purpose-built for it. See the receipt printer API guide for the whole pattern space, and port 9100 printing for what network printers speak underneath.
Frequently asked questions
- Can a browser print to a receipt printer silently, with no dialog?
- Not through window.print() without controlling the machine (kiosk flags). Silent printing requires one of the four byte-level routes: a local agent like QZ Tray, WebUSB/Web Serial in Chromium, a cloud relay called from your server, or an HTTP print API on the LAN.
- Does window.print() work at all for receipts?
- It can produce a readable receipt if the driver is configured for 80 mm roll paper, but it cannot cut, kick a cash drawer, or report paper-out, and it shows a dialog. It is a fallback, not a POS printing architecture.
- What is the cheapest way to print from a web app?
- If you control the workstation, WebUSB/Web Serial costs nothing but has the narrowest browser support. QZ Tray is free until you need silent printing at scale, where certificate plans run $599+/yr. LAN devices are a one-time hardware cost with no metering.
- Why does my HTTPS web app fail to reach a printer on the LAN?
- Browsers block mixed content: an https:// page cannot fetch plain http:// LAN addresses, and the LAN service must also answer CORS preflights. Serve the front-end locally, relay via an on-site backend, or use managed-browser policies in kiosk deployments.
- Do any of these report paper-out or cover-open back to my app?
- Cloud relays report job state, not printer sensors. QZ Tray exposes limited driver status. WebUSB can read status only if the printer exposes an IN endpoint. A LAN print API can poll the printer over DLE EOT and return real sensor state as JSON — and should say 'unknown' when it can't read, rather than guessing.
Related reading
- WebUSB and Web Serial Receipt PrintingPrinting to thermal printers straight from Chrome with WebUSB/Web Serial: working code, browser support reality, and where the approach breaks down.
- 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.
- Port 9100 Printing: Raw TCP for POS, ExplainedHow raw port 9100 printing works, why every POS uses it, how to test it with netcat, and its blind spots — status, discovery, and error handling.