Nearly every thermal receipt printer — the one at the checkout counter, the kitchen ticket printer, the one inside a self-service kiosk — speaks the same language: ESC/POS. Epson defined it, the industry adopted it, and today Star, Citizen, Bixolon, and the countless inexpensive brands are all compatible.
Once you understand it, receipt printing stops being "print an image somehow" and becomes something much simpler: writing a byte stream to a device. This guide covers how to build that byte stream, and the three ways to get it into the printer.
What ESC/POS actually is
An ESC/POS payload is plain text interleaved with control commands. Commands start with ESC (0x1B) or GS (0x1D) followed by an opcode. The ones you'll actually use:
| Command | Bytes | Effect |
|---|---|---|
ESC @ | 1B 40 | Initialize the printer (start every receipt with it) |
ESC a n | 1B 61 n | Alignment: 0 left / 1 center / 2 right |
ESC E n | 1B 45 n | Bold: 1 on / 0 off |
GS ! n | 1D 21 n | Character size (width/height multipliers) |
GS V m | 1D 56 m | Cut the paper (42 00 = partial cut) |
ESC p | 1B 70 ... | Kick open the cash drawer |
Text prints as-is; \n feeds one line. That's the whole model.
Building a receipt in Node.js
No printer SDK required — concatenating a Buffer is enough:
const ESC = 0x1b, GS = 0x1d;
const text = (s) => Buffer.from(s, 'ascii');
const receipt = Buffer.concat([
Buffer.from([ESC, 0x40]), // init
Buffer.from([ESC, 0x61, 0x01]), // center
Buffer.from([GS, 0x21, 0x11]), // double width + height
text('PRINTBASE COFFEE\n'),
Buffer.from([GS, 0x21, 0x00]), // normal size
text('2026-08-12 14:30 Order #1042\n'),
Buffer.from([ESC, 0x61, 0x00]), // left align
text('--------------------------------\n'),
text('Latte x1 4.50\n'),
text('Iced Americano x2 7.00\n'),
text('--------------------------------\n'),
Buffer.from([ESC, 0x45, 0x01]), // bold on
text('TOTAL 11.50\n'),
Buffer.from([ESC, 0x45, 0x00]),
text('\nThank you!\n\n\n'),
Buffer.from([GS, 0x56, 0x42, 0x00]), // partial cut
]);Hand-writing bytes is the best way to understand the model; in a real project a library like esc-pos-encoder produces the same Buffer with more readable code. For column alignment, remember the physical line width: 32 characters on 58mm paper, 48 on 80mm. Non-ASCII text needs the code page your printer expects (CP437, CP858, GBK for Chinese models) — encode with iconv-lite rather than sending UTF-8 blindly.
Three ways to deliver the bytes
1. Direct connection (USB / TCP / Bluetooth). Packages like escpos write straight to USB or to TCP port 9100. The constraint is the same as every direct approach: your Node.js process must be on the same machine or LAN as the printer. App in the cloud, printer in a shop — this path is closed.
2. Install a driver and treat it as a normal printer. The OS driver rasterizes your content into a bitmap. Paper comes out, but you lose command-level control — cuts, drawer kicks, and sizing become driver-settings roulette, and rasterized receipts print slower and blurrier.
3. Cloud RAW passthrough. A lightweight agent on any computer near the printer keeps an outbound connection to a cloud service. Your backend base64-encodes the ESC/POS bytes and makes one API call; the cloud pushes it to the agent, and the agent writes it to the printer unmodified — every byte intact. That's content_type: "raw" on PrintBase:
const res = await fetch('https://api.printbase.cloud/v1/print-jobs', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
printer_code: 1000000, // your receipt printer's code
content_type: 'raw',
content: receipt.toString('base64'),
}),
});
const job = await res.json();
// { id: "job_abc", status: "queued" }Call this when an order lands and the shop's printer fires within seconds — regardless of which cloud your backend runs in or which city the shop is in. Jobs move through queued → dispatched → printing → completed; failures come back with a reason code (PRINTER_OFFLINE, PRINT_ERROR, …), and webhooks can push those events to your endpoint so a failed kitchen ticket is reprinted before anyone notices.
Field-tested gotchas
- Garbage characters: almost always an encoding/code-page mismatch. Match what you send to what the printer is configured for.
- No cut: feed two or three
\nbeforeGS Vso the printed content clears the cutter first. - Misaligned columns: pad with spaces against the 32/48-character line width; tabs are not your friend.
- Truncated receipts: base64 inflates payloads by ~33% — watch request size on very long receipts, or split the job.
Want to try it against your own POS or ordering system? Start free — 50 jobs a month, no card — or read the API docs.
