Key Takeaway
An Epson TM-T82 prints ESC/POS byte streams, not web pages. A reliable custom POS builds the receipt as bytes, sends them straight to the printer over USB, serial or TCP port 9100, and lays text out in fixed 48-column lines rather than trusting a browser print dialog.
The first receipt I ever sent to an Epson TM-T82 came out as a page of Courier text with a two-centimetre margin, cut in the middle of the total, and it took the printer eleven seconds to produce. It was correct HTML. It was a useless receipt. That gap between what a web developer thinks printing is and what a POS printer thinks printing is cost me the better part of a week, and everything below is what I wish someone had written down before I started.
This is the byte-level view: what the hardware is, how the command language works, why column arithmetic replaces CSS, and where Indonesian text quietly breaks. Every claim here is either from Epson's own technical references or from a printer sitting on my desk.
Why the browser print dialog is the wrong tool
The tempting path is to render the receipt as HTML, call window.print, and pick the TM-T82 from the dialog. It works in a demo and fails in a shop, for four reasons that are all structural rather than fixable.
- It goes through the graphics pipeline. The driver rasterises your page into a bitmap the width of the paper and sends it dot by dot, so a receipt that should take under a second takes many, and the printer sounds like it is struggling because it is.
- It cannot open the cash drawer or cut where you want. Both are ESC/POS commands, and a rendered page has nowhere to put them.
- It requires a human. The print dialog is a modal that a cashier has to dismiss, on every sale, forever, and the one time they pick the wrong printer nobody notices until the end of the shift.
- Its layout is a lie. The browser lays out proportional text; the printer lays out a fixed grid of character cells. Anything you centre or align in CSS lands somewhere else on paper.
The alternative is not harder, only less familiar: you build an array of bytes and write it to a device. There is no rendering step at all, which is exactly why it is fast and exactly why it is predictable.

What a TM-T82 actually is
Before writing any code, it helps to know the numbers you are designing against. Epson's own specifications for the T82 family are unusually concrete, and they explain nearly every layout constraint you will hit later.
| Property | Value on the TM-T82 family |
|---|---|
| Print method | Thermal line printing — one horizontal line of dots at a time, no ink, no ribbon |
| Speed | Up to 150 mm per second on the TM-T82, which is roughly a full receipt per second |
| Resolution | 203 x 203 dpi, i.e. 8 dots per mm, with a 72 mm print width on 80 mm paper |
| Character grid | Font A is 12 x 24 dots giving 48 columns; Font B is 9 x 17 dots giving 64 columns |
| Endurance | Around 60 million lines MCBF and an auto cutter rated near 1.5 million cuts |
Read the character grid row twice, because it is the single most useful fact in this article. The printer has no proportional fonts and no arbitrary positioning: every line is a sequence of identical cells, and your entire layout budget is 48 of them. Design in that grid and everything lines up; fight it and nothing does.

The smallest receipt that works
ESC/POS is a byte protocol Epson introduced for its TM printers and which most of the industry now imitates. Commands begin with ESC, hex 1B, or GS, hex 1D, followed by a letter and its parameters; anything that is not a command is printed literally. That is the whole mental model.
// The whole "hello receipt" in raw ESC/POS. No driver, no dialog.
const ESC = 0x1b, GS = 0x1d, LF = 0x0a;
const bytes = Buffer.concat([
Buffer.from([ESC, 0x40]), // ESC @ initialize
Buffer.from([ESC, 0x74, 0x00]), // ESC t 0 code page 437
Buffer.from([ESC, 0x61, 0x01]), // ESC a 1 centre
Buffer.from([GS, 0x21, 0x11]), // GS ! double height + width
Buffer.from([ESC, 0x45, 0x01]), // ESC E 1 emphasized on
Buffer.from("TOKO SUMBER REJEKI\n", "ascii"),
Buffer.from([ESC, 0x45, 0x00]), // ESC E 0 emphasized off
Buffer.from([GS, 0x21, 0x00]), // GS ! 0 back to normal size
Buffer.from([ESC, 0x61, 0x00]), // ESC a 0 left
Buffer.from("Jl. Diponegoro 12, Semarang\n", "ascii"),
Buffer.from("-".repeat(48) + "\n", "ascii"),
Buffer.from([LF, LF, LF, LF]), // feed past the tear bar
Buffer.from([GS, 0x56, 0x42, 0x00]), // GS V B 0 partial cut with feed
]);Notice what is absent: there is no page setup, no margins, no font loading and no confirmation. You initialise, you set a mode, you send text, you feed, you cut. The printer executes commands in the order they arrive in its buffer, which is also why a cut command placed too early cuts through the total.
Keep a hexdump of one known-good receipt in your repository as a fixture. When a receipt goes wrong in production, diffing the bytes you sent against that fixture finds the bug in seconds, and it survives every refactor of the code that generates them.
Column arithmetic is the layout engine
With 48 fixed cells per line, right-aligning money is not a style, it is subtraction. Every layout helper in a POS printing library is some version of the two functions below, and writing them yourself takes ten minutes and removes a dependency.
const COLUMNS_FONT_A = 48; // 80 mm paper, 72 mm print width, 12x24 font
// One item line: name on the left, money hard against the right margin.
function itemLine(name: string, amount: string): string {
const room = COLUMNS_FONT_A - amount.length - 1;
const label = name.length > room ? name.slice(0, room - 1) + "." : name;
return label.padEnd(room + 1, " ") + amount;
}
itemLine("Kopi Susu Gula Aren", "25.000");
// "Kopi Susu Gula Aren 25.000"
// Wrap, never truncate silently, when the product name is genuinely long.
function wrapName(name: string, width = COLUMNS_FONT_A - 10): string[] {
const words = name.split(" ");
return words.reduce<string[]>((lines, word) => {
const last = lines[lines.length - 1];
if (last && (last + " " + word).length <= width) {
lines[lines.length - 1] = last + " " + word;
} else {
lines.push(word);
}
return lines;
}, []);
}Two rules save most of the pain. First, never let a product name silently truncate a price off the end of the line: reserve the amount's width first and give the name whatever remains. Second, decide once whether long names wrap or ellipsise and apply it everywhere, because a receipt where some lines wrap and others do not reads as broken even when the arithmetic is right.
Code pages: where Indonesian receipts break
The printer does not speak UTF-8. It holds a set of single-byte character tables and prints whichever one is currently selected, chosen with ESC t. Send UTF-8 bytes without thinking and accented characters arrive as pairs of symbols, because each byte of the multi-byte sequence is printed as its own glyph.
- Default to code page 0, PC437, and encode your text to it before sending. Plain Indonesian is ASCII, so ordinary item names and rupiah amounts pass through untouched.
- Strip or transliterate before printing rather than hoping. A product name pasted from a supplier catalogue can carry a curly apostrophe or a non-breaking space that has no cell in any code page.
- If you genuinely need a wider character set, select the matching table explicitly per block of text and switch back afterwards, the way open-source stacks such as python-escpos do with their code page mapping.
The currency symbol deserves its own decision. I print amounts as bare grouped digits and put the word Rp in a header line, which avoids depending on any code page having a rupiah glyph and keeps the digits aligned in the same cells on every line.
Rupiah amounts are the one place where a rendering bug becomes a financial dispute. Format the number once, in one function, with an explicit grouping separator, and print the result of that function everywhere — receipt, screen and report — so a customer holding paper and a manager reading a dashboard can never see different totals.
A print path that survives a busy Saturday
Printing is I/O against a physical device that jams, runs out of paper and gets unplugged by whoever is mopping. The path below is what I settled on after the first month of complaints.
- Persist the receipt first. Write the payload and the payment id to a queue table inside the same transaction that closes the sale, so a crash between payment and print leaves evidence rather than a mystery.
- Send from a single worker. One process owns the printer, drains the queue in order and never interleaves two receipts, which matters because interleaved ESC/POS is indistinguishable from corruption.
- Bound every wait. A connect timeout, a write timeout and a maximum attempt count turn an unplugged printer into a visible queued job instead of a hung cashier screen.
- Make reprints boring. A reprint is a new attempt against the same payment id, prints a clearly marked duplicate, and is available to the cashier without a manager password. If reprinting is hard, staff will re-run the sale.
That structure also gives you the metric that matters operationally: how many receipts are queued and how old the oldest one is. A printer that has been offline for four minutes shows up as a number before it shows up as a queue of annoyed customers.
What I would do differently
I spent too long trying to make one code path serve both an 80 mm counter printer and a 58 mm mobile printer. The command language is the same, but 48 columns and 32 columns are different layouts, not the same layout at different sizes, and pretending otherwise produced receipts that were technically correct and visually wrong on both.
I would also have tested with cheap paper from day one. Every receipt looks good on the sample roll in the box. The rolls a shop actually buys are thinner and less sensitive, and they are the ones that reveal whether your contrast and emphasis choices hold up.
Printing to a TM-T82 stops being difficult the moment you stop treating it as a printer in the desktop sense and start treating it as a serial device that accepts a small command language. Build the bytes, own the transport, respect the 48-column grid, and the hardware becomes the most reliable part of the whole point-of-sale system.

