If you've built a POS, restaurant or warehouse web app, you've hit the receipt-printer wall: the browser wants to show a print dialog, the cashier wants the receipt now, and the printer wants raw bytes.
Here's what I learned getting a web page to print to a thermal receipt printer with no dialog, including two gotchas that cost me real time.
Option 1: window.print()
Works everywhere, but:
- it always shows a dialog (outside kiosk mode)
- it adds page margins and scaling you don't control
- it can't cut the paper or open the cash drawer
Fine for the occasional print, painful at a busy counter.
Option 2: WebUSB / Web Serial
No install, but it's Chromium-only, needs a user gesture to pair the device, and on Windows it fights the installed printer driver for the USB interface. It's OK for kiosks you fully control.
Option 3: a local agent (what most POS apps end up doing)
A small program on the shop PC listens on 127.0.0.1, and your page calls it with fetch(). It talks to the printer through the OS print queue or a network socket. Three things to get right:
1. Chrome's Local Network Access rules
An https:// site calling http://127.0.0.1 triggers a CORS preflight that must be answered with:
Access-Control-Allow-Private-Network: true
And if you pass the newer targetAddressSpace fetch option, it has to match the real address space. In Chrome 155, targetAddressSpace: 'local' for a loopback address makes the fetch fail outright. 'loopback' (or no option) works:
await fetch('http://127.0.0.1:17777/v1/health', { targetAddressSpace: 'loopback' });
2. Security
Anything listening on localhost can be called by any website the user opens. At minimum:
- bind to
127.0.0.1only, never0.0.0.0 - check the
Originheader against an allowlist (browsers set it, and pages can't fake it) - reject requests whose
Hostheader isn't a loopback name, which blocks DNS-rebinding attacks - require an API key for non-browser callers (no
Originheader)
3. Printer width is never what the spec sheet says
This one surprised me. "80 mm" printers differ in DPI (180 vs 203), some have a 58 mm mode switch, and they all keep their own margins. My BIXOLON SRP-352plusIII is 576 dots wide on paper (the spec sheet), but in practice only 568 dots print. Text at the very right edge got clipped.
If you build receipts out of fixed 48-character text lines, they'll wrap or clip differently on the next printer you meet. The approach that fixed it for me: render the receipt as a bitmap at the printer's measured width (real TTF fonts, measured columns, QR codes, barcodes) and send it as an ESC/POS raster image. Then it looks the same on every brand.
How to measure: print a ruler image 640 dots wide with a tick every 8 dots and a number every 32 dots, each number ending at its tick. The last fully readable number (+8 per extra tick) is the usable width.
ESC/POS cheat sheet
| What | Bytes |
|---|---|
| Initialize | 1B 40 |
| Partial cut | 1D 56 01 |
| Kick cash drawer (pin 2) | 1B 70 00 19 FA |
| Raster image |
1D 76 30 00 xL xH yL yH + 1 bit per dot, MSB first |
Tips:
- Send raster images in bands of about 128 rows. Cheap printers overflow their buffer on one huge image.
- On Windows, send bytes through the spooler with datatype
RAW(OpenPrinter→StartDocPrinterwith"RAW"→WritePrinter). Then any installed driver works, and it won't re-render your bytes. - On macOS/Linux,
lp -d <printer> -o rawdoes the same through CUPS. - Network printers almost always accept raw bytes on TCP port 9100.
If you'd rather not build it
I packaged all of the above as Thermalink: a ~7 MB agent for Windows/macOS/Linux plus a small JS SDK. It has image mode (and a text mode for slow links), a calibrate command that prints that ruler, QR/barcodes/logos, cash drawer, label printers (ZPL/TSPL), and full source:
const tl = new Thermalink();
await tl.print(
Thermalink.receipt()
.title('BLUE DOOR CAFE')
.row('Flat white x2', '8.00')
.row('TOTAL', '8.00', { bold: true })
.qr('https://example.com/r/A1B2C3')
.cut()
);
It's a paid, one-time download: https://7207381545467.gumroad.com/l/xwyogp. Disclosure: I built it. Everything in this article works without it too. Happy to answer ESC/POS questions in the comments.
Top comments (0)