Building Bluetooth features in iOS sounds straightforward. Until you try to do it, that is.
The problem? Core Bluetooth is low-level. You’re not working with clean APIs or easy-to-parse values.
You’re dealing with raw bytes, flags, and device quirks that aren’t always in the documentation.
But once you understand how the whole flow works, from scanning to connecting to reading and writing, it gets a lot easier to build something stable. And even kind of fun.
In this guide, we’ll walk you through the full Bluetooth Low Energy (BLE) workflow on iOS using a real-world example: an iOS app that connects to and controls a treadmill via Bluetooth.
We’ll show you how to discover devices, parse byte-level data, and handle all the weird stuff manufacturers don’t warn you about.
Let’s dive in!
What is Core Bluetooth in iOS app development?
Core Bluetooth is Apple’s framework for building apps that connect to BLE devices, or serve as BLE peripherals. for building Bluetooth Low Energy (BLE) apps.
It gives you everything you need to scan, connect to, and interact with nearby BLE devices like fitness machines, heart rate monitors, smart lights, or locks.
It’s not a wrapper around classic Bluetooth. It’s BLE-only.
That means small data packets, tight power budgets, and a much lower-level way of working with data.
You don’t just press a button and get a result – you need to know what you’re talking to and how.
Take our treadmill project. We wanted to control speed and incline from an iOS app. The treadmill didn’t come with documentation. No SDK. Just a Bluetooth interface that claimed to support FTMS, the Fitness Machine Service.
That’s where Core Bluetooth came in. It let us:
- Scan for nearby BLE devices
- Identify the treadmill by its advertisement data
- Connect to it
- Explore its services and characteristics
- Write raw commands to change speed and incline
- Read current workout data (workout time, speed, incline)
No UI frameworks and no abstraction layers – just bytes.
It sounds simple. But behind the scenes, it’s a web of roles, delegates, UUIDs, and binary parsing. You need to understand the Bluetooth architecture before you even get to the logic.
So no, Core Bluetooth isn’t “plug and play.”
But it’s the tool that gives you full control when you want to make iOS apps that talk to the real world.
And full control is exactly what you need.
Central vs. peripheral: Bluetooth roles on iOS
Bluetooth has two main roles: Central and Peripheral.
The Central is the one that connects to other devices. In almost every project we’ve worked on, that’s been the iOS app.
Your iPhone or iPad acts as the Central, scans for nearby devices, connects to them, and reads or writes data.
The Peripheral is the device being connected to. It’s usually some kind of smart hardware. Think treadmills, heart rate monitors, bike trainers, smart locks, even electric toothbrushes.
In our treadmill project, the iPhone acted as the Central. The treadmill was the Peripheral. It broadcasted its availability using BLE advertisement packets.
Once connected, it exposed services and characteristics we could talk to – like “Training Data” and “Control Point.”
Sometimes people assume that smart devices are always the “active” ones. But in Bluetooth terms, being “smart” doesn’t make a device the Central. It just makes it capable of exposing data.
One thing to keep in mind: the roles can be reversed in theory.
But it usually doesn’t make sense in practice. You wouldn’t want your treadmill to start looking for nearby phones to connect to.
In Bluetooth docs, you’ll sometimes see the Peripheral described as a server. That’s because it holds the data or the endpoints that the Central (or client) interacts with. It’s the same logic you see in networking.
So, to keep it simple:
- Your iOS app = Central (client)
- The device = Peripheral (server)
That’s the mental model we use. It hasn’t failed us yet.
How Bluetooth connection works on iOS
When you’re building a Bluetooth feature on iOS, there’s a flow you should always follow.
We’ve used this in every BLE project we’ve done. Here’s what that flow looks like:
1. Create a CBCentralManager
This is the heart of your Bluetooth logic. It handles scanning, connecting, and keeping track of Bluetooth state.
In our treadmill app, we initialized it when the app launched:
let centralManager = CBCentralManager(delegate: self, queue: nil)
2. Wait for the state to become .poweredOn
Bluetooth might not be ready immediately. So we waited for:
func centralManagerDidUpdateState(_ central: CBCentralManager) { ... }
Once it was .poweredOn, we started scanning.
3. Call scanForPeripherals
According to the standard, you should scan by filtering devices directly based on the service UUIDs you need – meaning passing a non-nil parameter. This keeps discovery efficient and focused.
However, in the real world, many manufacturers have flexible or inconsistent implementations.
Because of that, it’s common to fall back to scanning for all nearby devices (passing nil) and then filter them manually by checking the advertisement data.
This approach helps you handle multiple devices in crowded test environments while still following best practices whenever possible.
4. Handle scan results
Every time a BLE device was found, we got this callback:
centralManager(_:didDiscover:advertisementData:rssi:)
We checked the name, service UUIDs, and RSSI.
If it matched the right criteria (and wasn’t too far away), we tried to connect.
5. Connect to the peripheral
We called:
centralManager.connect(peripheral, options: nil)
One detail that caused issues early on – Core Bluetooth won’t keep a peripheral alive if you don’t.
We had a few failed connections until we realized we weren’t retaining the peripheral. Rookie mistake, easy fix.
6. Wait for the connection callback
Once connected, we got:
centralManager(_:didConnect:)
At this point, we stopped scanning, set the peripheral’s delegate, and moved on to discovering services.
This is the baseline. Everything else builds on top.
In practice, we always wrap these steps in a connection manager. It helps handle timeouts, retries, and unexpected disconnects.
And yes – we’ve seen all of that.
Especially during the early days of testing with pre-release hardware. Devices would hang mid-connection or silently disconnect after a few seconds.
Following this flow tightly helped us debug those issues faster.
If you’re working with Core Bluetooth, commit it to memory. You’ll be using it more than you think.
Scanning and connecting to Bluetooth devices
Once Bluetooth is ready, scanning is your first real step. This is how your app discovers nearby devices.
In our treadmill app, we kicked off scanning right after the manager became .poweredOn. We used:
centralManager.scanForPeripherals(withServices: nil, options: nil)
We usually filter by the service UUID to keep scans focused.
But sometimes, like with the treadmill, we passed nil to get all nearby devices and then filtered by name later. It’s a bit noisy, but it gets the job done.
Every time a peripheral is discovered, Core Bluetooth sends you:
centralManager(_:didDiscover:advertisementData:rssi:)
This gives you more than just the device name. You get:
- Advertised service UUIDs
- Local name
- Manufacturer data
- RSSI (signal strength)
In the treadmill project, the device didn’t broadcast a clear name. But the manufacturer data had a pattern we could use.
We checked the bytes and matched it that way.
You can also use RSSI filtering and ignore anything below -80 dBm – that way, you won’t try connecting to devices in other rooms or behind walls.
It’s a small trick, but helps a lot during on-site testing if you have multiple devices close by.
Once we found a matching device, we stopped scanning:
centralManager.stopScan()
Then we connected:
centralManager.connect(peripheral, options: nil)
Simple, right?. But here’s something that bit us early on – you need to retain the peripheral reference.
If you don’t, Core Bluetooth will drop it and the connection will fail silently.
After the connect call, we waited for:
centralManager(_:didConnect:)
That confirmed we were in. From there, it was time to explore what the device had to offer.
In short, this scan → filter → connect flow is the foundation. Every BLE app starts here.
It looks clean on paper. But in real projects, things get messy with duplicate devices, flaky signal strength, and unexpected disconnects.
That’s why we always wrap scanning and connecting into a manager with logs and fallbacks. And with a timer to retry if something stalls.
It’s the only way to stay sane when you’re testing in the wild.
Discovering services on BLE peripherals
Once you’ve connected to a peripheral, the next step is discovery.
You’re not just connected, you now need to figure out what the device can actually do.
We’ve done this with a bunch of different devices – it’s the same process, every time.
Before doing anything else, you set the delegate:
peripheral.delegate = self
This is how you get callbacks. Without it, nothing else will work.
The next step is discovering services. You call:
peripheral.discoverServices(nil)
Passing nil means “give me everything.” That’s usually what we do during testing.
In production, we filter by known service UUIDs, especially when devices expose a lot of unnecessary stuff.
When the results came back, we handled:
peripheral(_:didDiscoverServices:)
We checked each service UUID and picked the one we cared about – the Fitness Machine Service.
Inside each service are characteristics, i.e. the actual data points and control interfaces.
For example, in the treadmill:
- One characteristic gave us training data (speed, incline, duration).
- Another was the control point – this is where we sent commands.
We called:
peripheral.discoverCharacteristics(nil, for: service)
Again, nil means “get all characteristics in this service.” Once they were found, we got:
peripheral(_:didDiscoverCharacteristicsFor:error:)
From there, we matched UUIDs and assigned references to the ones we needed.
Services = capabilities, Characteristics = data + controls. That’s how we think about it.
Services tell you what the device can do. Characteristics let you use it.
It’s where your app stops being passive and starts talking to the hardware.
And if the device follows a Bluetooth spec like FTMS, things are easier, but don’t expect full consistency. You still have to test everything yourself.
Getting this part right is crucial.
If you mess up discovery, the rest of the flow falls apart. That’s why in all our projects, we log every UUID, every characteristic, and every step of the discovery phase.
It’s the fastest way to debug weird device behavior and avoid surprises during a live demo.
How to read and write Bluetooth data on iOS
Once you’ve discovered the right characteristics, it’s time to use them.
This is where the real interaction starts. You’re communicating with hardware – reading values, writing commands, subscribing to updates.
Every Bluetooth device handles this differently. But the tools are always the same.
There are two ways to get data from a BLE characteristic: read and notify.
Read is a one-time request for the current value – like a snapshot.
For the treadmill using FTMS, the app would read the control point characteristic once to get the initial state after connecting.
After that, it subscribed to notify updates for all ongoing changes during the session.
The call looks like this:
peripheral.readValue(for: controlPointCharacteristic)
When the value arrives, Core Bluetooth calls:
peripheral(_:didUpdateValueFor:error:)
Notify is continuous. You subscribe to receive updates pushed by the device whenever the value changes.
But, writing is where it gets really tricky.
The most common use case? Sending commands. Start. Stop. Set speed.
We used this with the control point characteristic on the treadmill.
To change speed, we had to write a specific byte sequence, including flags, command codes, and speed value packed as two bytes.
You call:
peripheral.writeValue(data, for: controlPointCharacteristic, type: .withResponse)
There are two write types:
- .withResponse – The device confirms receipt. Safer, but slower.
- .withoutResponse – Fire-and-forget. Faster, but not always supported.
Whether you’re reading or writing, the structure behind it is nearly identical.
Core Bluetooth doesn’t care what kind of interaction it is, it just routes everything through the same callbacks. That’s where things start to feel more consistent, even if the data itself isn’t.
Every time you read or get a notification, Core Bluetooth calls:
peripheral(_:didUpdateValueFor:error:)
Same with write confirmations (if you’re using .withResponse):
peripheral(_:didWriteValueFor:error:)
This makes error handling consistent. If something goes wrong, you’ll see it here.
Reading and writing with Core Bluetooth is low-level work. There are no built-in types, no abstractions, and everything is done with raw bytes (Data).
That means you need to:
- Pack commands manually
- Convert numbers to bytes
- Understand endianness
- Parse flags in bitmasks
We’ll dig into that next. But for now, if your reads and writes aren’t working, you should check your characteristic properties.
Some are read-only. Some only accept notifications. Others require writes with response. And a few won’t talk to you unless you’ve requested control first.
BLE is fast and efficient. But it makes you work for it.
Parsing byte-level Bluetooth data in iOS apps
This is where things get real.
Reading and writing values is (relatively) simple. Understanding what those values mean, however, that’s the hard part.
Core Bluetooth gives you a byte array. It doesn’t explain it – that’s your job.
In our treadmill app, when we read training data, we didn’t get something friendly. No JSON. No key-value dictionary.
We got:
[0x11, 0x00, 0x96, 0x00, 0x03, 0x00]
The first byte? A flag. The next two? Speed. Then maybe incline or duration, but only if the flags say so.
This is common in Bluetooth. Payload structure is conditional and you can’t assume fixed offsets.
That first byte told us what was inside:
let flags = data[0]
let speedIncluded = flags & 0x01 != 0
let inclineIncluded = flags & 0x02 != 0
So if speed was present, we read two bytes at offset 1:
let speed = data.toUInt16(offset: 1)
But if incline wasn’t included, then the next value shifted up to offset 1. We had to adjust every offset dynamically based on those flags.
In one internal prototype, we built a BLE device with temperature and humidity sensors. It had one characteristic for data – tightly packed.
- Byte 0: flags
- Byte 1–2: temperature
- Byte 3–4: humidity
- Byte 5–6: checksum
Pretty simple on paper. But we still had to:
- Extract two-byte values
- Divide by 100 to get decimals
- Check the checksum
- Reverse the byte order if needed
Because the sensor firmware used Little Endian. But Core Bluetooth doesn’t tell you that.
In the treadmill, speed was sent as an integer. 150 meant 1.50 km/h.
let rawSpeed = data.toUInt16(offset: 1)
let speed = Float(rawSpeed) / 100.0
Simple. But only once you figure it out.
At first, we thought 150 was the number of seconds. Or maybe steps. Took a bit of testing to realise it was just scaled.
Incline worked the same way – but with a scale factor of 10 instead of 100.
Endianness will trip you up. Some devices use Little Endian. Others use Big Endian. Some mix them in the same payload.
We once saw incline data flipped because we assumed the wrong order. Instead of 5.0%, we got 1280%. Not great.
The fix was to swap bytes manually:
let value = UInt16(data[offset + 1]) << 8 | UInt16(data[offset])
After that, the data made sense again.
Writing means packing your own bytes. Sending commands is just the reverse.
To set speed on the treadmill, we had to build a payload manually:
[0x02, 0x96, 0x00]
Where:
- 0x02: start command
- 0x96 0x00: speed = 150 → 1.5 km/h
No Core Bluetooth helper. Just bytes and math.
We used Data extensions to handle this cleanly:
extension Data {
static func fromUInt16(_ value: UInt16) -> Data {
let lower = UInt8(value & 0xFF)
let upper = UInt8((value >> 8) & 0xFF)
return Data([lower, upper])
}
}
This made payloads easier to build and debug.
But even with “standard” devices, the byte layout can change.
We updated firmware on the treadmill once and the flags flipped. Speed came before the control point. And the incline field moved entirely.
Good thing we logged everything – raw payloads, parsed values, flags. It helped us trace the issue in minutes.
If you’re working with BLE, this is where most of your time goes. Parsing isn’t fun. But it’s necessary.
There are no shortcuts here. Just bytes, offsets, flags, and patience.
Common Core Bluetooth issues
Core Bluetooth works. But it doesn’t always behave the way you expect.
You can follow the flow perfectly and still get nothing. No connection. No data. No logs. Just silence.
We’ve hit all of these problems. More than once. Here’s what to watch for and how we handled it.
Problem 1: no data after connecting.
You’ve connected to a peripheral. Called discoverServices. And nothing happens.
We saw this on the treadmill. The issue? We forgot to set the peripheral’s delegate:
peripheral.delegate = self
Without it, no discovery callbacks are triggered. Everything looks fine, but nothing works.
We now set the delegate immediately after connecting every time.
Problem 2: connection silently fails.
You call connect(peripheral, options: nil). Then…nothing. No success. No failure.
In our early treadmill builds, this happened often. The cause? We weren’t retaining the peripheral.
Core Bluetooth doesn’t hold a strong reference for you. If your app lets the CBPeripheral instance go out of scope, the connection fails.
We fixed this by keeping a reference:
self.activePeripheral = peripheral
Simple. But easy to overlook when you’re moving fast.
Problem 3: notifications not triggering.
You called setNotifyValue(true, for:). But you never get any updates.
If the characteristic only allows reading and doesn’t support notifications. You need to double-check the characteristic’s properties:
if characteristic.properties.contains(.notify) { ... }
If it’s like this, it doesn’t.
Core Bluetooth won’t throw an error if you try to enable notifications on a characteristic that doesn’t support them. It just doesn’t do anything.
So now we always check the properties first.
Problem 4: write commands ignored.
We sent a command to the treadmill and nothing happened. No response and no error.
The issue? The treadmill required .withResponse. We used .withoutResponse.
Once we switched:
peripheral.writeValue(data, for: controlPointCharacteristic, type: .withResponse)
It worked immediately.
Some devices only respond to one write type. If you’re not sure, always start with .withResponse. It’s safer.
Problem 5: wrong value interpretation.
We once got wildly incorrect speed readings – like 650 km/h. We thought it was a parsing bug.
Turns out the device firmware changed. It switched from Little Endian to Big Endian. No warning.
We fixed it by adjusting our byte parsing logic. And now? We test for both during development, just in case.
Problem 6: reconnecting doesn’t work.
You disconnect. Then try to connect again. And nothing happens. This happened when we were quickly switching between devices during testing.
The fix was to call cancelPeripheralConnection(_:) and wait for:
centralManager(_:didDisconnectPeripheral:)
Before starting a new scan. If you skip that, Core Bluetooth sometimes gets stuck.
These aren’t edge cases. They’re normal when working with BLE.
So we’ve built our Bluetooth tools to expect failure. We add timers. Retry logic. Logs for every step – scan, connect, discover, read, write.
When something breaks (and it will), we want to know why.
That’s the only way to make Bluetooth features stable and production-ready.
Real-world Bluetooth workarounds for iOS devs
Bluetooth development isn’t just about following the spec.
Even when a device claims to support something like FTMS, you can’t trust that it behaves exactly the way the standard says it should.
We’ve seen inconsistencies, bugs, undocumented quirks and had to work around every single one of them.
Here are a few that stand out.
First, you should request control.
Some devices require an explicit “request control” command before accepting anything else.
It’s a good idea to send this control request as the very first thing after connecting, just to be safe.
Second, devices can drop you if you stay silent.
One of our early test sessions ended with a treadmill that disconnected mid-session after 60 seconds.
We hadn’t touched it. No error, just a clean disconnect.
Turns out the device expected a regular heartbeat. If it didn’t receive any write or read requests for too long, it assumed the session was over.
Our fix? A keep-alive.
We wrote a tiny status command and sent it every 15 seconds. Even if the user wasn’t actively adjusting anything, the treadmill saw the traffic and kept the session alive.
This trick saved us from constant disconnects during long workouts.
Here’s another weird one: you need to send full payloads, even if only one value changes.
In our treadmill integration, if we sent a command to change just the speed, the incline would reset to zero.
That’s not how it should work. But it’s how this device behaved.
We fixed it by always sending both speed and incline, even if only one of them was changing.
It was more verbose, but it worked. Anything less confused the firmware.
This wasn’t a mistake on our end. It was just how that treadmill was designed. We’ve seen similar quirks in other prototypes too.
Also, some devices lie about their characteristic properties.
One of our test peripherals said its control point characteristic supported .writeWithoutResponse.
It didn’t.
When we used that write type, the command failed silently. Nothing changed. No error was thrown. Just no effect.
We switched to .withResponse – and it worked. We now treat .writeWithoutResponse as “maybe.” If it doesn’t work, we retry with .withResponse.
And this fallback is now part of our Bluetooth manager by default.
Also, sometimes errors come as values and not actual errors.
Some devices don’t use the error parameter in Core Bluetooth delegate methods. They just send back a custom error byte in the value.
In the treadmill project, we got a 0x80 response after sending a command – paired with 0x05, meaning “control not permitted.” It wasn’t a system error. It was the device telling us we hadn’t requested control yet.
We added a response parser to handle this.
Now, if we get certain codes back from the device, we know exactly what to show the user or what to fix in the command structure.
And remember, “standard” doesn’t mean consistent. We’ve tested three fitness devices that all claim to support FTMS.
Every one of them behaved differently:
- Different UUIDs
- Different payload structures
- Different write requirements
- Different control unlock sequences
Even with the same spec, you have to treat each device like a unique implementation.
That’s why we never assume anything. We test everything. We parse every response. And we build in fallbacks wherever possible.
These aren’t bugs. They’re just the reality of working with hardware.
And if you want to build a Bluetooth feature that works in the real world, you need to prepare for this.
No two devices behave exactly the same. So your app has to be smart, resilient, and ready to adapt.
Core Bluetooth iOS apps: FAQs
No, Core Bluetooth only works with Bluetooth Low Energy (BLE).
Devices that use classic Bluetooth, like older speakers or car audio systems, aren’t supported.
Yes, technically. iPhones and iPads can advertise themselves as peripherals and expose services and characteristics.
But in most cases, iOS apps are used as Centrals, connecting to external peripherals like fitness machines, sensors, or wearables.
Peripheral mode also comes with limitations, like background execution and power constraints, that can make it tricky to use in real apps.
Common causes include:
- The peripheral expects a keep-alive signal and disconnects when idle
- The app hasn’t retained a strong reference to the peripheral
- Bluetooth signal interference or low battery on the device
It’s also important to handle disconnects properly and implement retry logic when needed.
Conclusion
Bluetooth on iOS isn’t magic.
It’s a low-level, byte-by-byte process that takes work, patience, and a lot of testing.
But once you understand the flow (and all the quirks that come with it) you can build apps that talk to the real world.
Whether it’s a treadmill or your own custom device, Core Bluetooth gives you the tools. You just need to know how to use them.
And if you’re into this kind of deep-dive, head over to our engineering hub – there’s plenty more where this came from!