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 40 to initialize, 1D 56 00 to 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-printing flag) 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 right Access-Control-Allow-Origin headers. 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 plain http:// 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 TrayWebUSB / Web SerialCloud relay (PrintNode)LAN print API
Install per workstationJava agent + certificatenone (browser only)none (client per site)none
Extra hardware per sitenonoPC/Mac/Pi running clientthe LAN device/node
Works with internet downyesyesnoyes
Browser supportall majorChrome/Edge onlyall (server-side call)all major
Printer status (paper/cover)limited, driver-dependentpossible, often write-onlyjob-state onlyyes, as JSON
Recurring cost$0–2,999/yr (certificates/support)$0$9–500/mo, metered per print$0 software; device cost
Sharpest edgecert + agent lifecycleWindows driver swap, no Safari/Firefoxoffline = no printingCORS/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