nanda mochammad
Engineering Notes

Printing thermal receipts from an iPad: ESC/POS over Bluetooth

19 min read

There is no Swift package that just works. I learned this the hard way, byte by byte, while wiring an iPad POS to one of those palm-sized 58mm thermal printers that every warung and kios in Indonesia seems to own: the unbranded kind you buy on Tokopedia or Shopee for under two hundred thousand Rupiah. It has no MFi certification, so it never appears in the Bluetooth settings the way an AirPod does. It ships with an Android app and a one-page leaflet in approximate English. There is no SDK. There is, in the end, only a stream of bytes you have to get exactly right.

That sounds like bad news, and for an afternoon it is. But once you stop looking for an abstraction that doesn’t exist and accept that you are talking to the printer in its own language, the problem becomes small and manageable. ESC/POS is a small, stable, forty-year-old protocol. Core Bluetooth is verbose but predictable. This piece is the field guide I wish I’d had: what the protocol actually is, how to find the printer over Bluetooth Low Energy, the three or four traps that eat your time, and a clean ReceiptBuilder you can paste into a real app and print a Rupiah receipt with today.

Create the project

Open Xcode and choose File → New → Project → iOS → App, then set:

  • Product Name: ReceiptPOS
  • Interface: SwiftUI, Language: Swift
  • Storage: None

Create it, then point it at the modern SDK: select the project in the navigator → the ReceiptPOS target → GeneralMinimum Deployments → set iOS to 26.0.

One setting is mandatory before any Bluetooth code runs. Core Bluetooth terminates the app the instant it touches the central manager unless you have declared why you need Bluetooth. Select the target → the Info tab → add a row with key NSBluetoothAlwaysUsageDescription and a plain value like Connects to the receipt printer over Bluetooth. There is no package to add: the printer code is all standard-library Core Bluetooth.

Check. Press ⌘B. The untouched template should build with no errors. That’s your known-good starting line; every file below drops into a Printing/ group inside this project.

Where these files live

The code here is small enough to drop into one Printing/ group in your app. Three files do the work, and a fourth is the SwiftUI view that triggers a print:

Printing/
├── PrinterManager.swift   // Core Bluetooth: scan, connect, write in chunks
├── ReceiptBuilder.swift   // ESC/POS command bytes + the fluent builder
└── PrintButton.swift      // a SwiftUI view that composes a receipt and prints it

To create them: in the Xcode navigator, right-click the ReceiptPOS group → New Group → name it Printing; then right-click PrintingNew File from Template… → Swift File three times to create PrinterManager.swift, ReceiptBuilder.swift, and PrintButton.swift. ReceiptBuilder.swift collects several code blocks below (the ESC enum, the String extension, the helper functions, and the ReceiptBuilder struct) stacked top-to-bottom in the order they appear here; they all go in that one file.

Each Swift block below carries a header comment naming the file it belongs to, so you can see where it goes.

Why it’s hard

The abstraction you wanted was never built

The instinct is to search for pod 'ThermalPrinter' and move on. Don’t. The generic printers sold here are clones of clones; each implements a slightly different subset of ESC/POS, advertises different Bluetooth service UUIDs, and lies about a few things in between. A package that wraps “the printer” is wrapping an average of devices that don’t quite agree, and the day yours disagrees, you are debugging someone else’s abstraction instead of your own bytes. This is Joel Spolsky’s Law of Leaky Abstractions in its purest, most physical form: the convenient wrapper leaks the moment the hardware misbehaves, and you pay the cost of the abstraction plus the cost of understanding what it hid.

So we go down a layer on purpose. The good news is that the layer is shallow. The path a receipt travels has exactly three hops, and you own the first two.

Diagram: the path a receipt travels: your Swift app encodes ESC/POS bytes, Core Bluetooth writes them in chunks to the printer's GATT write characteristic, and the printer's firmware interprets the bytes and feeds paper. YOUR APP · iPad ReceiptBuilder [UInt8] of ESC/POS CORE BLUETOOTH GATT write chunked to MTU THERMAL PRINTER Firmware interprets · feeds write BLE raw control codes no MFi, no SDK, just bytes no acknowledgement
The whole pipeline. Your app encodes a flat array of bytes, Core Bluetooth writes them to the printer's GATT characteristic in MTU-sized chunks, and the printer's firmware interprets them and feeds paper. No MFi, no SDK, no acknowledgement: just bytes going one direction.
The protocol

ESC/POS in ten minutes

ESC/POS is a command language Epson defined for receipt printers, and the cheap clones imitate it because the whole ecosystem expects it. There is no framing, no packet header, no handshake. You send bytes; most printable bytes are printed as text; a few special bytes, chiefly ESC (0x1B) and GS (0x1D), mark the start of a command that changes the printer’s mode or makes it do something physical, like cut the paper.

The mental shift that makes everything click: the printer has no concept of a “heading” or a “total line.” It holds a small amount of state (current alignment, current text size, bold on or off) and you mutate that state as you stream. Set centre alignment, print the shop name, set it back to left. The printer is a small state machine you drive one byte at a time.

Diagram: the opening byte sequence of a real receipt: initialize the printer, centre the text, double its size, print the shop name, then reset and left-align. Each command is one to three bytes; printable text is just ASCII in between. BYTES (hex) MEANING 1B 40 1B 61 01 1D 21 11 "Kopi Senja\n" 1D 21 00 1B 61 00 ESC @ reset ESC a 1 centre GS ! 0x11 2× wide + tall printable text ASCII + line feed (0x0A) GS ! 0 normal size ESC a 0 left The printer keeps no model of "a heading"; it holds a mode, mutated byte by byte. Set it, print, set it back. ESC = 0x1B · GS = 0x1D · LF = 0x0A. Everything between commands is printed verbatim.
The opening of a receipt, byte for byte. Initialise, centre, go double-size, print the shop name, then put size and alignment back. Each command is one to three bytes; everything between commands is printed verbatim as ASCII.

You only need a handful of commands to print a complete receipt. Here are the ones that earn their place:

CommandBytes (hex)MeaningNote
ESC @1B 40Initialise / resetSend first, always
ESC a n1B 61 nAlign: 0 left, 1 centre, 2 rightState: persists until changed
ESC E n1B 45 nBold: 1 on, 0 offReset it or the next line is bold too
GS ! n1D 21 nText size; 0x11 = double w+hHigh nibble width, low nibble height
LF0APrint buffer + feed one lineYour line terminator
ESC d n1B 64 nFeed n blank linesWhitespace before the cut
GS V m1D 56 00Cut the paperMany cheap units ignore this

That is most of it. Epson’s official command reference documents hundreds more (barcodes, bitmaps, code pages, print density) but the seven above compose a clean receipt. Treat the reference as the source of truth when a clone does something odd; treat this table as your day-to-day kit.

The transport

Core Bluetooth: scan, connect, find the pen

With the bytes settled, the second hop is Bluetooth. These printers speak BLE, so this is Core Bluetooth, not the External Accessory framework, which is exactly why no MFi certification is needed and why this works at all. The flow is the standard GATT dance: scan for peripherals, connect, discover services, discover characteristics, and find the one characteristic you can write to. That writable characteristic is the “pen” you push bytes through.

Start the central manager and scan. In production you would filter by the printer’s advertised service UUID, but generic units vary, so I scan broadly and match on name or let the user pick from a list.

// Printing/PrinterManager.swift
import CoreBluetooth

final class PrinterManager: NSObject, ObservableObject {
    @Published var discovered: [CBPeripheral] = []
    @Published var isReady = false

    private var central: CBCentralManager!
    private var printer: CBPeripheral?
    private var writeCharacteristic: CBCharacteristic?

    override init() {
        super.init()
        central = CBCentralManager(delegate: self, queue: nil)
    }

    func startScan() {
        guard central.state == .poweredOn else { return }
        // nil = discover everything; cheap clones rarely filter cleanly
        central.scanForPeripherals(withServices: nil)
    }

    func connect(_ peripheral: CBPeripheral) {
        central.stopScan()
        printer = peripheral
        peripheral.delegate = self
        central.connect(peripheral)
    }
}

The delegate callbacks walk you down to the write characteristic. The key detail is the .write / .writeWithoutResponse property check. That is how you find the pen, and which kind of write the printer supports decides how you pace your data later.

// Printing/PrinterManager.swift
extension PrinterManager: CBCentralManagerDelegate, CBPeripheralDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state == .poweredOn { startScan() }
    }

    func centralManager(_ central: CBCentralManager,
                        didDiscover peripheral: CBPeripheral,
                        advertisementData: [String: Any], rssi RSSI: NSNumber) {
        guard peripheral.name?.isEmpty == false,
              !discovered.contains(peripheral) else { return }
        discovered.append(peripheral)
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        peripheral.discoverServices(nil)
    }

    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        peripheral.services?.forEach { peripheral.discoverCharacteristics(nil, for: $0) }
    }

    func peripheral(_ peripheral: CBPeripheral,
                    didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        for ch in service.characteristics ?? [] where
            ch.properties.contains(.write) || ch.properties.contains(.writeWithoutResponse) {
            writeCharacteristic = ch
            DispatchQueue.main.async { self.isReady = true }
            return
        }
    }
}

Remember to add NSBluetoothAlwaysUsageDescription to Info.plist, or the app is killed the instant you touch the central manager. On iPad that string is the only permission gate between you and the printer.

The trap

MTU limits and the disappearing tail

Here is the trap that cost me the most time, and it’s worth slowing down for. A BLE write is not a stream. It is a single packet, and a packet has a maximum size called the MTU. After negotiation you typically get somewhere around 180-185 usable bytes per write on iOS; some links give you less. A full receipt is easily 400-800 bytes. If you hand all of it to one writeValue(_:for:type:) call, the behaviour ranges from “the framework splits it for you” to (far more often on these clones) “the printer prints the first chunk and silently drops the rest.” You get the header and the first two items, and the total line is simply gone. No error. No log. Just a short receipt and a confused cashier.

The fix is to slice the payload into MTU-sized chunks and write them in order.

Diagram: a 600-byte receipt payload cannot be written in one BLE operation. It is sliced into chunks that fit the negotiated MTU (here about 180 bytes each) and written one after another, so the whole receipt arrives intact instead of being silently truncated. FULL PAYLOAD · ~600 bytes one [UInt8]: too big for a single write slice to MTU CHUNKS · ≤ MTU each, written in order chunk 1 180 B chunk 2 180 B chunk 3 180 B chunk 4 60 B · tail With .withoutResponse, pace the writes (or watch canSendWriteWithoutResponse); overrun the buffer and the tail of the receipt just vanishes.
A 600-byte receipt cannot go out in one write. Slice it to the negotiated MTU, send the chunks in sequence, and the whole thing arrives. Skip this and the tail of the receipt, usually the total, vanishes without any error.
// Printing/PrinterManager.swift — add this method INSIDE the PrinterManager class (before its closing brace)
func send(_ payload: [UInt8]) {
    guard let printer, let ch = writeCharacteristic else { return }

    // Ask the link what one write can hold, then leave a little headroom.
    let useResponse = ch.properties.contains(.write)
    let type: CBCharacteristicWriteType = useResponse ? .withResponse : .withoutResponse
    let mtu = printer.maximumWriteValueLength(for: type)
    let chunkSize = max(20, min(mtu, 180))

    var offset = 0
    while offset < payload.count {
        let end = min(offset + chunkSize, payload.count)
        let chunk = Data(payload[offset..<end])
        printer.writeValue(chunk, for: ch, type: type)
        offset = end
    }
}

This is the moment The Pragmatic Programmer keeps in your head: don’t program by coincidence. It is tempting to add a Thread.sleep between writes, see it work once, and ship it. But a sleep that “fixes” printing is a coincidence, not an explanation. It will break on a busier device or a longer receipt. Knowing it is an MTU and buffer problem means you fix the actual cause and trust the result.

The composition

Building a receipt in Swift

Now the satisfying part. Composing a receipt is building a [UInt8] array: append command bytes, append text encoded to bytes, append a line feed. Raw, it looks like assembly, and writing raw command bytes at every call site is how you end up with that stuck-bold bug. So wrap each command once, in a named helper, and the intent reads clearly.

// Printing/ReceiptBuilder.swift
enum ESC {
    static let initialize: [UInt8] = [0x1B, 0x40]       // ESC @
    static let boldOn: [UInt8]     = [0x1B, 0x45, 0x01] // ESC E 1
    static let boldOff: [UInt8]    = [0x1B, 0x45, 0x00] // ESC E 0
    static let alignLeft: [UInt8]  = [0x1B, 0x61, 0x00] // ESC a 0
    static let alignCenter: [UInt8] = [0x1B, 0x61, 0x01]
    static let alignRight: [UInt8] = [0x1B, 0x61, 0x02]
    static let doubleSize: [UInt8] = [0x1D, 0x21, 0x11] // GS ! double w+h
    static let normalSize: [UInt8] = [0x1D, 0x21, 0x00]
    static let cut: [UInt8]        = [0x1D, 0x56, 0x00] // GS V 0
    static func feed(_ n: UInt8) -> [UInt8] { [0x1B, 0x64, n] } // ESC d n
}

Encoding text needs one bit of care: receipt printers are not Unicode. They use single-byte code pages, so emoji and many accented characters won’t render. For Indonesian and English you are safe with ASCII; encode with a Latin fallback so a stray character degrades instead of crashing.

// Printing/ReceiptBuilder.swift
extension String {
    var receiptBytes: [UInt8] {
        Array(data(using: .ascii)
              ?? data(using: .isoLatin1)
              ?? Data())
    }
}
The local reality

Rupiah, 32 columns, and clone quirks

This is where a generic tutorial stops being useful and real shop work begins. Three things matter for an Indonesian POS receipt.

Rupiah formatting. Prices want grouped thousands and an Rp prefix: Rp 25.000, not 25000. Indonesian uses a dot as the thousands separator, so pin the locale rather than trusting the device’s region.

// Printing/ReceiptBuilder.swift
func formatRupiah(_ amount: Int) -> String {
    let f = NumberFormatter()
    f.numberStyle = .decimal
    f.locale = Locale(identifier: "id_ID")  // dot as thousands separator
    f.maximumFractionDigits = 0
    let number = f.string(from: NSNumber(value: amount)) ?? "\(amount)"
    return "Rp \(number)"
}

The 32-character line. A 58mm printer in normal font fits 32 characters per line. To align an item name on the left and its price on the right (the look every receipt has) you pad the gap yourself. There is no layout engine; you are counting columns.

// Printing/ReceiptBuilder.swift
func row(_ left: String, _ right: String, width: Int = 32) -> String {
    let gap = max(1, width - left.count - right.count)
    return left + String(repeating: " ", count: gap) + right
}
// row("Kopi Susu x2", "Rp 36.000") -> "Kopi Susu x2          Rp 36.000"

Clone quirks. Budget units are inconsistent, and you should plan for it rather than be surprised. Many ignore GS V (the cutter), so feed several blank lines before the cut as a fallback and let the user tear the paper. Some need ESC @ between receipts or they inherit the previous job’s mode. A few drop to a default code page on power-cycle. Reset often and feed generously; paper is cheaper than a support call.

The payoff

A reusable ReceiptBuilder

Pulling it together: a small fluent builder that accumulates bytes and hands you a finished [UInt8] to send. This is the API a teammate can use without ever reading the ESC/POS reference: the abstraction we wanted, except now we wrote it and we know exactly what it hides.

// Printing/ReceiptBuilder.swift
struct ReceiptBuilder {
    private var bytes: [UInt8] = ESC.initialize

    func center() -> Self { append(ESC.alignCenter) }
    func left() -> Self { append(ESC.alignLeft) }

    func title(_ text: String) -> Self {
        append(ESC.alignCenter)
            .append(ESC.doubleSize)
            .append(ESC.boldOn)
            .append(text.receiptBytes).append([0x0A])
            .append(ESC.boldOff)
            .append(ESC.normalSize)
            .append(ESC.alignLeft)
    }

    func line(_ text: String = "") -> Self {
        append(text.receiptBytes).append([0x0A])
    }

    func item(_ name: String, _ price: Int) -> Self {
        line(row(name, formatRupiah(price)))
    }

    func divider() -> Self { line(String(repeating: "-", count: 32)) }

    func total(_ amount: Int) -> Self {
        append(ESC.boldOn)
            .line(row("TOTAL", formatRupiah(amount)))
            .append(ESC.boldOff)
    }

    func cut() -> Self { append(ESC.feed(4)).append(ESC.cut) }

    func build() -> [UInt8] { bytes }

    private func append(_ more: [UInt8]) -> Self {
        var copy = self; copy.bytes += more; return copy
    }
}

And the call site that produces a complete sale, readable enough to hand to anyone:

// Printing/PrintButton.swift — inside the print action
let receipt = ReceiptBuilder()
    .title("Kopi Senja")
    .center().line("Jl. Tunjungan No. 12, Surabaya").left()
    .divider()
    .item("Kopi Susu x2", 36_000)
    .item("Roti Bakar", 18_000)
    .divider()
    .total(54_000)
    .line()
    .center().line("Terima kasih!").left()
    .cut()
    .build()

printerManager.send(receipt)

From button tap to receipt

The last hop is the UI that fires that call. On the iOS 26 SDK the standard SwiftUI chrome (toolbars, sheets, navigation bars) adopts Liquid Glass on its own, so most screens need no extra work. For the print action itself, a Liquid Glass button reads as the floating control it is: it sits above the order, not inside it. .buttonStyle(.glassProminent) gives you that look in one line, and the button does nothing more than compose a receipt and hand it to the printer manager.

// Printing/PrintButton.swift
import SwiftUI

struct PrintButton: View {
    @ObservedObject var printerManager: PrinterManager

    var body: some View {
        Button("Print receipt") {
            let receipt = ReceiptBuilder()
                .title("Kopi Senja")
                .center().line("Jl. Tunjungan No. 12, Surabaya").left()
                .divider()
                .item("Kopi Susu x2", 36_000)
                .item("Roti Bakar", 18_000)
                .divider()
                .total(54_000)
                .line()
                .center().line("Terima kasih!").left()
                .cut()
                .build()

            printerManager.send(receipt)
        }
        .buttonStyle(.glassProminent)
        .disabled(!printerManager.isReady)
    }
}

isReady flips true once PrinterManager has found a writable characteristic, so the button stays disabled until there is actually a pen to write through. That closes the loop: a tap composes the bytes, send(_:) chunks them to the MTU, and the printer feeds paper.

Now wire it into the screen. Open the generated ContentView.swift, delete Xcode’s default VStack { Image(systemName: …); Text("Hello, world!") } body, and replace the whole file with this:

// ContentView.swift — replace Xcode's default body
import SwiftUI

struct ContentView: View {
    @State private var printerManager = PrinterManager()

    var body: some View {
        PrintButton(printerManager: printerManager)
    }
}

This is the single place PrinterManager() is created and held, which is why PrintButton takes it as a parameter rather than making its own. (PrinterManager is an ObservableObject, and PrintButton observes it with @ObservedObject, so holding it here with @State is fine.)

Check. Press ⌘B. With all three Printing/ files and this ContentView edit in place, the project should now compile: your first full green build before going to device.

Run it on the iPad. Build to a real iPad running iOS 26 (⌘R; Core Bluetooth does nothing in the simulator). You don’t pair these printers in iOS Settings; the clones aren’t MFi, so the app scans for one directly over Bluetooth Low Energy. Power the printer on, let the button enable itself once a writable characteristic is found, and tap Print receipt. You should hear the feed motor and get a 58mm slip like the one below.

[ Photo: the printed 58mm receipt from the code above: “Kopi Senja” in double-size bold, two items, a bold total of Rp 54.000, and a clean tear at the feed. ]
Placeholder for a real artefact. A photo can't be drawn here.

That is the entire job: encode bytes, write them in chunks, respect the printer’s quirks. Once you can see the protocol, the next time someone says “the printer doesn’t work,” you won’t reach for another package. You’ll reach for the byte stream and read what you actually sent.

Receipt prints wrong: where to look

Nothing prints at allno write characteristic→ Check .write property + Info.plist key
Prints, then ends earlytail dropped→ Chunk to MTU, pace the writes
Everything is bold / hugemode never reset→ Pair each command with its off
Garbled letters / wrong symbolscode page mismatch→ Encode ASCII / ISO-Latin-1, send ESC @

Almost every thermal-printer bug is one of these four. Start from the symptom, not the library.

The reason this knowledge keeps paying off is that it doesn’t expire. ESC/POS has barely changed in decades, Core Bluetooth is stable, and the cheap printers will keep arriving in cardboard boxes with leaflets in broken English. An afternoon spent learning the bytes pays back every time, and it is, almost by definition, an article only the person who fought the hardware can write.

Cited sources