Tukri is a point-of-sale app for small shops in Bangladesh. Shopkeepers asked for receipts to come straight out of a cheap Bluetooth thermal printer when they tap Print, with no print window in between. This is how we did it from a web app, and the traps we hit on the way.
The problem with sending text
Cheap 58 mm and 80 mm receipt printers speak ESC/POS. Their built-in fonts have no Bangla letters, and their code pages have no Bangla letters or taka sign either. So we cannot send text and ask the printer to print it.
Text never travels. The browser draws it, and only dots go down the wire.
The pipeline
- The server composes the receipt as finished lines: text, left/right pairs, rules, the logo, barcode bars. They are already translated, in the shop's digits, with every amount formatted from integer paisa. The browser computes no money and chooses no words.
- The browser draws the lines on a canvas exactly as wide as the print head: 384 dots for 58 mm paper and 576 for 80 mm at 203 dpi, so nothing is scaled after it is drawn. Bangla is drawn with Noto Sans Bengali, so the browser shapes the conjuncts and the printer's own fonts are never involved.
- The canvas becomes a 1-bit image with a plain threshold, not a dither. The receipt is black text and black bars on white, and a dither would scatter grey dots along the very bar edges a scanner reads.
-
The image goes out as ESC/POS raster commands:
ESC @to initialise,GS v 0for the picture,ESC d nto feed the paper past the tear bar, and a partial cut (GS V 66 0) only when the printer is saved as having a cutter. No text mode, no code page, no font commands: those are where the cheap clones differ.
The picture is sent in bands of 128 rows. Several clones have small receive buffers and drop the tail of an oversized image, and 128 rows of a 384-dot head is 6 KB a band.
// Simplified: one bitmap becomes several GS v 0 commands.
function raster(bitmap: Bitmap, bandRows = 128): Uint8Array[] {
const commands: Uint8Array[] = [];
for (let start = 0; start < bitmap.height; start += bandRows) {
const h = Math.min(bandRows, bitmap.height - start);
const header = Uint8Array.of(
0x1d, 0x76, 0x30, 0x00, // GS v 0, normal size
bitmap.bytesPerRow & 0xff, bitmap.bytesPerRow >> 8,
h & 0xff, h >> 8,
);
commands.push(concat(header, rowBytes(bitmap, start, h)));
}
return commands;
}
Why the server sends lines instead of a PDF
Our PDFs are rendered on the server, and we wanted to keep one renderer for them. Turning a PDF into dots on the server would have needed an image dependency we do not have, and the image library we do have cannot shape Bangla conjuncts. So the server also produces the receipt as lines, and a test renders the PDF template and checks that every line of the direct receipt appears in it, so the two cannot quietly drift apart.
Getting the bytes to the printer
- Android, Bluetooth: Web Serial over Bluetooth Classic (the Serial Port Profile), not Web Bluetooth. Most cheap printers speak that profile. Our help article asks for Chrome 138 or newer on Android.
const SPP = '00001101-0000-1000-8000-00805f9b34fb';
const port = await navigator.serial.requestPort({
allowedBluetoothServiceClassIds: [SPP],
filters: [{ bluetoothServiceClassId: SPP }],
});
await port.open({ baudRate: 9600 });
- Bluetooth LE printers: Web Bluetooth only lets a page open services it named up front, so a printer on a service we did not list cannot be reached at all. We list only the services documented by the MIT-licensed WebBluetoothReceiptPrinter driver and never add a guessed UUID.
- Computers: Chrome or Edge, with USB or serial.
- iPhone and iPad: no browser there can reach a Bluetooth or USB printer. Tukri turns those choices off on those devices and says why, and the option left is AirPrint through the system print window.
Never resend after bytes have left
If a write fails halfway, we cannot know what is on the paper. So an error carries how many bytes were sent, and anything above zero is marked ambiguous and is never retried by itself. A write that timed out is not retried either.
The one exception is a link that died while idle (the printer was switched off and on): its first write fails with nothing sent, so no paper can have moved, and only that case reconnects and sends once more. Half a receipt followed by a whole one is worse than an error message. Print jobs also run one at a time, through a queue.
The trap: awaiting abort
When a write stalls, we abort the writer. But await writer.abort() never returns, because abort settles only after the in-flight write it is aborting, which is exactly the thing that is stuck. So every chunk gets a timeout, and the abort is fired and forgotten.
const writer = port.writable.getWriter();
try {
for (const chunk of chunks(bytes, 512)) {
await withTimeout(writer.write(chunk), 15_000);
sent += chunk.length;
}
} catch (error) {
// Not awaited: abort() waits for the stuck write.
void writer.abort().catch(() => undefined);
throw new PrintError({ bytesSent: sent });
} finally {
writer.releaseLock();
}
Labels have their own gotchas
Label printers that speak TSPL take the same kind of bitmap, with two traps. In TSPL a SIZE without a unit means inches, so we always write mm. And in BITMAP a 0 bit prints, the opposite of ESC/POS, so the rows have to be inverted.
What we have actually tested
- On an Android phone, a Bluetooth printer connected and printed the test receipt and new sales' receipts from the installed app. That is the only path we have seen on paper.
- On an iPhone nothing prints, by design.
- USB, Bluetooth LE, label printers and other printer models have only run against simulated devices in our tests. Simulated devices prove our bytes, not that a given printer accepts them, so we do not call those paths working yet.
Testing a phone against a dev server without HTTPS
Web Serial needs a secure context, and localhost counts as one. So with adb reverse tcp:8168 tcp:8168 a phone can open http://localhost:8168 on your dev machine over USB. If the phone's USB port is holding the printer through an OTG adapter, use wireless adb instead. A saved printer belongs to an origin, so localhost and 127.0.0.1 each need their own setup.
Takeaways
- If the printer cannot print your language, draw the page yourself and send dots.
- Put what the receipt says on the server and how it is drawn in the browser, and test that the two agree.
- Threshold, do not dither, anything a scanner has to read.
- Band the raster and keep the commands to the subset every clone implements.
- After the first byte leaves, never resend by yourself.
- Do not claim a printer works until you have seen paper.
Tukri is at tukri.bd, with a free plan.
Disclosure: we are the Tukri team. This post was written by an AI agent from our own codebase and project notes, and published by the Tukri team.
Top comments (0)