The short version
- Thermal printers speak ESC/POS: a byte protocol from the 1990s. You send bytes over a serial link, the printer obeys.
- 58mm printers are 384 dots wide, 80mm are 576 dots, at 203 dpi. Get this wrong and every receipt is misaligned.
- Android can use classic Bluetooth SPP. iOS generally cannot unless the printer is MFi-certified — choose BLE printers if iOS matters.
- Non-Latin scripts such as Hindi, Punjabi and Cyrillic will not print as text. Render them to a raster image and send that.
- Over BLE you must chunk writes to the negotiated MTU and pace them, or the printer silently drops output.
MyDigitalBill is a billing app for small retailers. The core loop is unglamorous and completely essential: ring up a sale, hand over a paper receipt. The printers involved cost about the same as a nice dinner, arrive with no documentation, and communicate using a protocol designed for point-of-sale terminals in the early nineties.
It works well now. Getting there involved more trial and error than it should have, mostly because the useful information is scattered across vendor PDFs and forum posts. This is the version I would have wanted.
What ESC/POS actually is
ESC/POS is a command language Epson introduced for receipt printers, and effectively every cheap thermal printer now implements some subset of it. There is no handshake, no acknowledgement, no error channel. You open a serial connection and push bytes. The printer does what the bytes say.
Commands are escape sequences. ESC @ (bytes 0x1B 0x40) resets the printer. ESC a 1 centres text. GS V cuts the paper. Everything that is not a command is treated as characters to print.
This is liberating and unforgiving in equal measure. There is very little to go wrong conceptually, and almost no feedback when it does. If a receipt comes out blank, the printer will not tell you why.
Assume nothing is confirmed. If you did not see paper move, it did not print.
Paper width is the first decision
Before any code, establish the physical width, because every layout decision depends on it.
| Paper | Print width | Bytes per line | Characters (Font A) |
|---|---|---|---|
| 58 mm | 384 dots | 48 | 32 |
| 80 mm | 576 dots | 72 | 48 |
Those figures assume 203 dpi, which is standard for this class of hardware. A handful of 58 mm units only address 372 dots, so print a test page and count before you trust a datasheet.
The character counts matter because receipt layout is monospaced text, not a layout engine. A two-column line — item on the left, price on the right — is padding arithmetic:
String row(String left, String right, {int width = 32}) {
final space = width - left.length - right.length;
if (space < 1) {
// truncate the item name rather than wrap unpredictably
final keep = width - right.length - 1;
left = left.substring(0, keep.clamp(0, left.length));
return '$left ${right}';
}
return left + ' ' * space + right;
}
Handle the overflow case explicitly. Long product names are the norm, not the exception, and a wrapped line silently destroys the alignment of everything below it.
The transport problem: SPP vs BLE
ESC/POS says nothing about how bytes reach the printer. In practice there are two Bluetooth routes and they are not interchangeable.
Classic Bluetooth SPP (Serial Port Profile) is what most inexpensive printers use. It behaves like a serial cable: connect, write, done. Throughput is good and you can send a whole receipt in one go.
Bluetooth Low Energy exposes a GATT service with a writable characteristic. You discover the service, find the characteristic, and write to it in small pieces.
On Android both are available. flutter_bluetooth_serial handles SPP; flutter_blue_plus handles BLE. Remember that Android 12 and above split the old permission into BLUETOOTH_SCAN and BLUETOOTH_CONNECT, and that scanning may also need location permission depending on how you declare it.
Why iOS is the hard part
Here is the constraint that should shape your hardware choice, ideally before anyone buys a printer.
iOS does not let third-party apps open classic Bluetooth serial connections to uncertified accessories. Communicating with a classic Bluetooth device requires it to be part of Apple’s MFi programme, and the generic printers sold online are not. The device may pair in iOS Settings and still be completely unreachable from your app.
BLE has no such gatekeeping. Any BLE peripheral is reachable through CoreBluetooth, and therefore through Flutter.
So the decision tree is short:
- Android only? Classic SPP is simpler and faster. Use it.
- iOS involved at all? Specify BLE printers, or MFi-certified hardware, and verify before committing.
Write this into the project scope. “Which printers do your shops already own?” is a question that belongs in the first conversation, because the answer can rule out a platform.
Building a receipt in Flutter
Generating the bytes is the pleasant part. esc_pos_utils_plus turns a declarative description into an ESC/POS byte list, and you stay out of raw escape codes.
final profile = await CapabilityProfile.load();
final gen = Generator(PaperSize.mm58, profile);
List<int> bytes = [];
bytes += gen.text('MY SHOP',
styles: const PosStyles(align: PosAlign.center, bold: true,
height: PosTextSize.size2));
bytes += gen.text('Ludhiana', styles: const PosStyles(align: PosAlign.center));
bytes += gen.hr();
bytes += gen.row([
PosColumn(text: 'Item', width: 8),
PosColumn(text: 'Amt', width: 4,
styles: const PosStyles(align: PosAlign.right)),
]);
bytes += gen.hr();
bytes += gen.feed(2);
bytes += gen.cut();
Keep receipt generation in a pure function that takes an invoice and returns List<int>. It has no Bluetooth dependency, so you can unit test it, and you can swap the transport later without touching layout.
The Unicode problem
This is the one that surprises people, and in India it surfaces immediately.
Thermal printers do not know about Unicode. They hold a set of single-byte codepages — CP437, CP850, CP1252 and so on — and print whatever glyph sits at that byte in the active page. Send Devanagari or Gurmukhi and you get question marks, blank boxes or cheerful nonsense.
There is no encoding trick that fixes this, because the glyphs are not in the printer. The reliable solution is to stop sending text and start sending pixels:
- Render the non-Latin block into an image — Flutter’s own text painting, or the
imagepackage. - Scale it to the exact print width in dots (384 for 58 mm).
- Convert to 1-bit monochrome with a threshold.
- Emit it with the raster command via
gen.image()orgen.imageRaster().
It is slower and uses more paper, so use it surgically: shop name and address as an image, the item table as plain text, totals as plain text. That keeps receipts fast while letting the header carry the language the customer actually reads.
Chunking, pacing and silent failures
If you are on BLE, you cannot hand the printer a 20 KB receipt and walk away. Writes are bounded by the negotiated MTU — conservatively around 180 to 200 bytes of payload after protocol overhead, sometimes far less on older hardware.
The printer also has a small buffer and no flow control worth relying on. Send too fast and it silently discards the overflow, which is how you get a receipt that stops mid-line with no error at all.
Future<void> send(BluetoothCharacteristic c, List<int> bytes) async {
const chunk = 180;
for (var i = 0; i < bytes.length; i += chunk) {
final end = (i + chunk < bytes.length) ? i + chunk : bytes.length;
await c.write(bytes.sublist(i, end), withoutResponse: false);
await Future.delayed(const Duration(milliseconds: 20));
}
}
Prefer acknowledged writes over write-without-response when the printer supports them; they are slower and dramatically more reliable. Then tune the delay down until output starts truncating, and set it back up with margin. Twenty milliseconds has been a safe starting point across the units I have tested.
Field notes
Things learned the expensive way, in no particular order.
- Always send
ESC @first. Printers keep state between jobs. A previous receipt that left bold or double-height enabled will corrupt yours. - Feed before cutting. The blade sits a few millimetres above the print head; without three or four line feeds you slice through the last line.
- Paper runs out mid-sale. There is usually no signal. Offer a visible reprint button rather than trying to detect it.
- Reconnect, do not re-pair. Cache the device identifier and reconnect silently on app start. Making a shopkeeper walk the pairing flow at every sale is a failed product.
- Print a test receipt from a settings screen. It is the fastest possible answer to “is it the printer or the app?” and it will save you hours of remote support.
None of this is difficult once you know it. It is just undocumented, and the failure mode is always the same — nothing happens, and nothing tells you why. Test on the exact hardware your users own, early.
Quick answers
Can a Flutter app print to a Bluetooth thermal printer on both iOS and Android?
Yes, but the transport differs. Android can talk to classic Bluetooth SPP printers and to BLE printers. iOS cannot use classic Bluetooth serial with uncertified hardware, so on iOS you need a Bluetooth Low Energy printer. Choose BLE hardware if you need both platforms.
How wide is a 58mm thermal printer in pixels?
A 58mm printer at 203 dpi is typically 384 dots wide, which is 48 bytes per line. An 80mm printer is usually 576 dots, or 72 bytes per line. Some 58mm models only use 372 dots, so always verify against a printed test page.
Why does my thermal printer print question marks instead of Hindi text?
Thermal printers do not render Unicode. They use single-byte codepages such as CP437 or CP1252, so any character outside the active codepage prints as a placeholder. To print Hindi, Punjabi or other non-Latin scripts, render the text to a bitmap and send it as a raster image.