Printing thermal receipts from an iPad: ESC/POS over Bluetooth
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 → General → Minimum 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 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) 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.
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.
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.
You only need a handful of commands to print a complete receipt. Here are the ones that earn their place:
| Command | Bytes (hex) | Meaning | Note |
|---|---|---|---|
| ESC @ | 1B 40 | Initialise / reset | Send first, always |
| ESC a n | 1B 61 n | Align: 0 left, 1 centre, 2 right | State: persists until changed |
| ESC E n | 1B 45 n | Bold: 1 on, 0 off | Reset it or the next line is bold too |
| GS ! n | 1D 21 n | Text size; 0x11 = double w+h | High nibble width, low nibble height |
| LF | 0A | Print buffer + feed one line | Your line terminator |
| ESC d n | 1B 64 n | Feed n blank lines | Whitespace before the cut |
| GS V m | 1D 56 00 | Cut the paper | Many 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.
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.
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.
// 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.
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())
}
}
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.
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.
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
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