nanda mochammad
Engineering Notes

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

20 min read

Hello, and welcome. This is a hands-on guide to printing receipts from an iPad on a cheap Bluetooth thermal printer, talking to it directly, byte by byte.

By the end of this article, you will understand what ESC/POS is, how to find and connect to the printer with Core Bluetooth, why long receipts get cut off (and how to fix it), and how to use a small ReceiptBuilder to print a full Rupiah receipt from a real iPad.

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. Every warung and kios in Indonesia seems to own one. It is the unbranded kind you buy on Tokopedia or Shopee for under two hundred thousand Rupiah. It has no MFi certification, so it never shows up in the Bluetooth settings the way an AirPod does. It ships with an Android app and a one-page leaflet in rough English. There is no SDK. In the end, there is only a stream of bytes you have to get exactly right.

That sounds like bad news, and for an afternoon it is. But then you stop looking for a wrapper that does not exist. You accept that you are talking to the printer in its own language. After that, the problem becomes small and easy to handle. ESC/POS is a small, stable, forty-year-old protocol. Core Bluetooth is wordy but predictable. This piece is the field guide I wish I had. It covers what the protocol really 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 → General → Minimum Deployments → set iOS to 26.0.

One setting is required before any Bluetooth code runs. Core Bluetooth kills the app the moment it touches the central manager unless you have said 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 Core Bluetooth.

Check. Press ⌘B. The untouched template should build with no errors. That is your known-good starting line. Every file below goes 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 Printing → New 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). Stack them top to bottom in the order they appear here. They all go in that one file.

Each Swift block below has 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 first instinct is to search for pod 'ThermalPrinter' and move on. Don’t. The generic printers sold here are clones of clones. Each one supports a slightly different subset of ESC/POS. Each one advertises different Bluetooth service UUIDs. Each one lies about a few things in between. A package that wraps “the printer” is really wrapping an average of devices that do not quite agree. The day yours disagrees, you are debugging someone else’s wrapper instead of your own bytes. This is Joel Spolsky’s Law of Leaky Abstractions in its most physical form. The handy wrapper leaks the moment the hardware misbehaves. Then you pay the cost of the wrapper 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. A receipt travels 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. There is no MFi, no SDK, and no acknowledgement; the bytes go one direction.
The protocol

ESC/POS in ten minutes

ESC/POS is a command language Epson defined for receipt printers. The cheap clones copy 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, mainly ESC (0x1B) and GS (0x1D), mark the start of a command. A command changes the printer’s mode or makes it do something physical, like cut the paper.

The printer has no idea what a “heading” or a “total line” is. It holds a small amount of state: current alignment, current text size, bold on or off. You change that state as you stream. Set centre alignment, print the shop name, set it back to left. The printer is a small state machine, and you drive it 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 make a clean receipt. Use the reference as the source of truth when a clone does something odd. Use 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. That 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. Look for the .write / .writeWithoutResponse property check. That is how you find the pen. Which kind of write the printer supports also 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. Without it, the app is killed the moment 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

This trap cost me the most time. 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 usually get 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, one of two things happens. Sometimes the framework splits it for you. 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. There is no error and no log, only 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. Once you know it is an MTU and buffer problem, you fix the real cause and can trust the result.

The composition

Building a receipt in Swift

Now the fun part. Composing a receipt means building a [UInt8] array. Append command bytes, append text encoded to bytes, append a line feed. Raw, it looks like assembly. 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 will not 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

Three things matter for an Indonesian POS receipt.

Rupiah formatting. Prices need grouped thousands and an Rp prefix: Rp 25.000, not 25000. Indonesian uses a dot as the thousands separator. So pin the locale instead of 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. Every receipt puts the item name on the left and its price on the right. To get that look, 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. Plan for it instead of being 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 keep the previous job’s mode. A few fall back to a default code page after a power cycle. Reset often and feed generously. Paper is cheaper than a support call.

The payoff

A reusable ReceiptBuilder

Now let’s pull it together. Here is a small fluent builder that collects bytes and hands you a finished [UInt8] to send. This is the API a teammate can use without ever reading the ESC/POS reference. It is the wrapper 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 here is the call site that produces a complete sale. It is 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 looks like the floating control it is. It sits above the order, not inside it. .buttonStyle(.glassProminent) gives you that look in one line. 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 to true once PrinterManager has found a writable characteristic. So the button stays disabled until there is really a pen to write through. 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 where PrinterManager() is created and held. That is why PrintButton takes it as a parameter instead of 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. This is 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 do not pair these printers in iOS Settings. The clones are not MFi, so the app scans for one directly over Bluetooth Low Energy. Power the printer on, wait for the button to enable itself once a writable characteristic is found, then 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 will reach for the byte stream and read what you 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.

Recap

The goal was to print a Rupiah receipt from an iPad to a cheap Bluetooth thermal printer, without waiting for a Swift package that does not exist. Here is what matters:

  • ESC/POS is a small command language with no framing. The printer is a state machine. Set a mode, print, and always set the mode back.
  • Core Bluetooth is the transport. Scan, connect, discover services and characteristics, and find the one you can write to. Add NSBluetoothAlwaysUsageDescription or the app is killed.
  • A BLE write is one packet with an MTU limit. Chunk every receipt to that size and send the chunks in order, or the total line disappears with no error.
  • Wrap each command in a named helper and use a small ReceiptBuilder, so the call site reads like a receipt instead of hex.
  • Plan for clone quirks. Start with ESC @, feed a few lines before the cut, pin id_ID, and count 32 columns on 58mm paper.

This knowledge does not 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.

I hope this writing helps you. Thanks for reading, and if it saved you an afternoon, share it with someone who is still fighting their printer.

Cited sources