Fresclean POS · Chapter 4 of 8

Printing from a browser

Jovanus Hartono2026

On this page

Receipts come out of a small A printer that burns text onto heat-sensitive paper, so it needs no ink, like the ones at supermarket tills. at the counter, a CBT-80 in the first store to get one. There's no driver and no print dialog. Chrome talks to the printer directly over Web Bluetooth, a browser API that lets a web page connect to a nearby Bluetooth device, and a receipt comes out on its own the moment an order is created. If a customer loses theirs, the cashier can print it again from the order. Safari has no Web Bluetooth, so on a device that can't print, the POS quietly skips it.

Printing was on my to-do list from the very first version of the POS, and it stayed there until the first store's printer arrived.

Speaking ESC/POS

Receipt printers speak ESC/POS, a stream of bytes where text is mixed in with commands. Each command is a byte or two with a fixed meaning:

CommandWhat it does
ESC @Resets the printer
GS VCuts the paper
GS ( kStarts a longer sequence that prints a QR code

ESC is 0x1b and GS is 0x1d, so ESC @ is the two bytes 0x1b 0x40.

I wrote a small builder for it, about a hundred lines, with a method for each command I needed:

export class EscPosBuilder {
  private readonly bytes: number[] = [];

  init(): this {
    return this.raw(ESC, 0x40); // ESC @: reset
  }

  bold(on: boolean): this {
    return this.raw(ESC, 0x45, on ? 1 : 0);
  }

  cut(): this {
    return this.raw(GS, 0x56, 0x42, 0x00); // GS V: cut the paper
  }

  // …align, size, feed, qr, and text()
}

A receipt is then a chain of calls: b.init(), b.align("center"), b.line(store.phone_number), and so on down the slip, ending in b.cut().

The paper is 576 dots wide and each character is 12 dots, so a line fits exactly 48 characters. Every column on the receipt is counted out in code. A price sits on the right because the gap between it and the treatment's name was padded with spaces:

  Deep Clean                          Rp 150.000

That counting has a trap. Budget printers print anything outside their current The set of characters a printer knows how to draw. Anything outside it comes out as random symbols. as garbage, so every string gets turned into plain ASCII before it's sent. "·" becomes "-", accents come off, and a few characters change length on the way ("½" becomes "12"):

export const toPrintableAscii = (value: string): string =>
  value
    .replaceAll(" ", " ")
    .replaceAll("·", "-")
    .normalize("NFKD") // "é" becomes "e" plus a separate accent
    .replaceAll(/[^\n -~]/g, ""); // then anything that isn't plain ASCII goes

normalize("NFKD") splits a character like "é" into a plain "e" and a separate accent mark, which the last line then drops. It's also what turns "½" into a 1, a fraction slash and a 2, and the slash is dropped too.

If the padding were measured before that conversion, the columns would drift. So the layout measures the text only after it's been converted, which is what actually hits the paper:

const row = (leftRaw: string, rightRaw: string): string => {
  const left = toPrintableAscii(leftRaw);
  const right = toPrintableAscii(rightRaw);
  const gap = WIDTH - left.length - right.length; // WIDTH is 48
  // …a line that's too long gets its left side trimmed
  return `${left}${" ".repeat(gap)}${right}`;
};
A receipt laid out the way the printer prints it, 48 characters to a line, for a made-up order. The pickup code and the QR code are what make it a claim ticket.

A connection that never gives up

Bluetooth printers are fussy. Android's Chrome would accept writes of around 512 bytes, but the printer's little The small bit of memory a device holds incoming data in until it can use it. fills up long before that, so data goes out in 120-byte chunks with a 30 ms breather between them:

for (let offset = 0; offset < data.length; offset += CHUNK_SIZE) {
  const chunk = data.slice(offset, offset + CHUNK_SIZE); // 120 bytes
  await characteristic.writeValueWithoutResponse(chunk);
  await sleep(CHUNK_DELAY_MS); // 30 ms
}

Then there's the printer that's switched off. You'd expect gatt.connect() to fail after a while. ( The part of Bluetooth that lets an app connect to a device and read or write its data channels. is how Bluetooth exposes a device's channels to an app.) In Chromium it doesn't. The call just keeps waiting until the printer shows up again (crbug 666377).

That matters because prints wait in line. An automatic print and a manual reprint can fire at the same moment, and if both sent their chunks at once, the paper would come out with two receipts shuffled together. So every print waits for the one before it. One connection that never finished would have stuck the whole line until someone reloaded the page, so connecting gives up after 10 seconds, by racing the connection against a timer with Promise.race:

const timeout = new Promise<never>((_, reject) => {
  timer = setTimeout(() => {
    device.gatt?.disconnect();
    reject(new Error("Printer is not responding — check it is on and in range"));
  }, CONNECT_TIMEOUT_MS); // 10 seconds
});

server = await Promise.race([device.gatt?.connect(), timeout]);

Whichever settles first wins, and a failed print never blocks the next one.

Whose printer is it?

The browser only shows its Bluetooth picker after someone taps a button, so an automatic print can never pair a printer. It can only reconnect to a printer this tab has already connected to. Pairing happens the first time a cashier presses print.

That picker lists every Bluetooth device nearby, which in a shop means phones, speakers and the printer next door. So each store registers its printers' names, and the picker only shows those. requestDevice takes a list of filters, one per saved name:

const device = await navigator.bluetooth.requestDevice({
  filters: deviceNames.map((name) => ({ name })),
  optionalServices: PRINTER_SERVICES,
});

A counter can't print to a stranger's speaker, or to a printer that belongs to another store.

Registering had a bug of its own, which I caught before it shipped. It used the open picker to pick the printer, and then quietly made that printer this device's own. Register the second counter's printer from the first counter's laptop, and the first counter's next receipt would have come out across the shop. Registering now connects once to prove the printer is real and in range, then hands it straight back. These printers only take one connection at a time, so holding on to it would stop the other counter from printing.

The names are also saved exactly as the printer announces them. If I tidied a name up before saving it, it would never match that printer again.

Five pairs on one slip

The first receipts were one long column, which falls apart when a family drops off five pairs. Nothing on the paper showed where one shoe's treatments ended and the next one's began.

So the receipt leaves a blank line between Items. Each pair starts with a bold row naming it, brand, model, colour and size, with its tag underneath, because the bold row is what the cashier's eye lands on when looking for one pair among five.

Notes needed wiring up too. The database had a column for a note on each treatment, and the API accepted and saved it. But the POS always sent nothing, and nothing ever read the column back. Now a note typed at intake, like "sol kanan lepas" (the right sole is coming off), rides the receipt to whoever works on the shoe.

Reprints brought one more rule. A line might be cancelled or refunded after the first print, and on paper a voided line looks just like a live one, so reprints mark those lines BATAL or REFUND. A cancelled order prints no pickup code at all, since there's nothing left to collect.

The receipt as claim ticket

The biggest change the printer brought wasn't technical. Before it, no staff screen ever showed a pickup code. Only the tracking page did, once a pair was ready. Once there was paper, it made more sense to treat the receipt as the claim ticket, the way a laundry counter does. Anyone holding the receipt could already look the order up and see the code once the pair was ready.

That changed an older rule of mine, and it's where the next chapter picks up.