Electronics Design AU
Bluetooth & BLE

How Do You Design a Custom BLE GATT Profile?

Last updated 2 July 2026 · 6 min read

Direct Answer

Designing a custom BLE GATT profile means grouping a device's data and controls into one or more services, each containing characteristics that expose individual values or commands. Use a Bluetooth SIG-assigned 16-bit UUID only if a standard service genuinely matches your use case (Battery Service, Heart Rate Service); otherwise generate a random 128-bit UUID for every custom service and characteristic to guarantee no collision with another vendor's profile. Each characteristic declares properties (Read, Write, Write Without Response, Notify, Indicate) that determine how a client can interact with it — Notify is preferred over Indicate for high-frequency sensor data because it doesn't wait for client acknowledgement, while Indicate is preferred for data where guaranteed delivery matters more than throughput, such as a critical alert or a command confirmation.

Detailed Explanation

Designing a GATT profile is fundamentally a data modelling exercise — deciding how a device's functionality maps onto services and characteristics — before it's a firmware implementation task. Getting the structure right up front avoids a costly companion-app and firmware rework later. For BLE protocol fundamentals (GAP, GATT hierarchy, advertising), see What Is Bluetooth Low Energy?; for the platform-specific implementation, see nRF52 BLE peripheral with Zephyr or ESP32 BLE fundamentals.

Services: Grouping Related Functionality

A Service groups a cohesive set of characteristics representing one functional area of the device — a temperature sensor product might have a single "Environmental Sensing" service containing temperature, humidity, and battery characteristics, or split battery status into the standard Battery Service if broader host compatibility for that specific value matters. There's no strict rule for how many services a device should expose, but grouping by genuine functional cohesion (not just convenience) keeps the profile intuitive for anyone implementing a client against it later, including your own future firmware or app engineers.

Standard vs Custom UUIDs

The Bluetooth SIG maintains a registry of standard 16-bit UUIDs for common service and characteristic types (Battery Service 0x180F, Device Information Service 0x180A, Heart Rate Service 0x180D, and dozens more). Using a standard UUID where your data genuinely matches its definition means many BLE host platforms and generic BLE apps already understand the service without any custom integration work.

For anything that doesn't cleanly match a standard definition — which is the common case for purpose-built IoT sensors, industrial controllers, and most genuinely novel products — generate a random 128-bit UUID for every custom service and characteristic. The 128-bit UUID space is large enough that a properly random UUID has a negligible collision probability with another vendor's custom profile; do not attempt to construct a "memorable" custom UUID by hand, since this defeats the collision-avoidance purpose of using the full 128-bit space.

Characteristic Properties

Each characteristic declares which operations a client can perform on it:

PropertyClient actionTypical use
ReadClient reads the current value on demandConfiguration values, static device info, infrequently-changing state
WriteClient writes a value, peripheral sends a write responseCommands requiring confirmation of receipt
Write Without ResponseClient writes a value, no response sentHigh-frequency control data where response overhead isn't worth the throughput cost
NotifyPeripheral pushes updates to a subscribed client, no client acknowledgementStreaming sensor data, frequent state updates
IndicatePeripheral pushes updates, client must acknowledge each oneAlerts, commands, or state changes where guaranteed delivery matters

A single characteristic can combine properties where it makes sense (e.g. Read + Notify, so a client can either poll the current value or subscribe to updates), but avoid declaring properties the application never actually uses — an unused Write property on a sensor-only characteristic adds attack surface and confuses client implementers about the characteristic's actual intended use.

Notify vs Indicate: Choosing Correctly

This is one of the most consequential design decisions in a GATT profile, because it directly trades off throughput against delivery guarantee. Notify doesn't wait for client acknowledgement before the peripheral can send the next update — appropriate for frequent sensor telemetry where an occasional dropped value is acceptable and low latency matters more. Indicate requires the client to send a Handle Value Confirmation before the next indication can be sent — appropriate for commands, critical alerts, or any value where the peripheral needs positive confirmation the client actually received it, at the cost of lower maximum throughput.

Both require the client to first write to the characteristic's CCCD (Client Characteristic Configuration Descriptor) to opt in — a peripheral never sends unsolicited notifications or indications to a client that hasn't explicitly subscribed. This CCCD write is per-connection: it does not automatically persist across a disconnect/reconnect cycle unless the client re-writes it (or the stack restores it from a bonded state on some platforms) — the CCCD-not-re-written-after-reconnect failure mode is common enough in practice to warrant its own forum discussion.

Data Encoding Within a Characteristic Value

GATT characteristic values are raw byte arrays — GATT itself has no concept of data types beyond what the SIG's standard characteristics define. For custom characteristics, define a clear, documented byte layout (endianness, fixed vs variable length, scaling factors for numeric sensor values) and keep it consistent across firmware revisions, since the companion app and firmware must agree on this encoding independently — a GATT client has no way to discover the encoding format from the protocol itself, only from documentation or a vendor-specific characteristic that describes the format.

Design Considerations

  • Model services around functional cohesion, not implementation convenience. A profile that groups characteristics logically is easier for companion app developers (including your own team, months later) to work with than one organised around firmware module boundaries that don't map to user-facing functionality.
  • Reserve Indicate for genuinely delivery-critical data. Overusing Indicate on high-frequency data throttles overall throughput unnecessarily, since every indication blocks on client acknowledgement before the next can be sent.
  • Document the byte-level encoding of every custom characteristic value explicitly — this is the single most common source of firmware/app integration bugs in custom GATT profiles, since there's no protocol-level mechanism to self-describe a custom value's format. Zeus Design designs BLE GATT profiles and matching companion apps together to keep this contract consistent across both sides.
  • Plan CCCD re-subscription behaviour explicitly on the client side, especially for mobile apps that may background/foreground frequently — don't assume a subscription persists across every possible connection state transition.

Common Mistakes

  • Hand-crafting a "readable" 128-bit UUID instead of generating a properly random one, increasing collision risk with another vendor's profile and generally indicating the UUID wasn't generated through a proper random UUID generation process.
  • Using Indicate by default "to be safe" on all characteristics, needlessly limiting throughput on data where an occasional dropped Notify would have been perfectly acceptable.
  • Forcing custom data into a mismatched standard service's semantics to avoid defining a custom UUID, which usually creates more confusion for client implementers than simply defining a clean custom service would have.
  • Not documenting the custom characteristic value encoding, leaving future companion app or firmware developers to reverse-engineer byte layouts from existing code rather than a clear specification.
  • Assuming CCCD subscription state survives every disconnect/reconnect scenario without verifying it on the actual target client platforms and BLE stack — this exact assumption is the root cause of one of the site's most common BLE forum threads.

Frequently Asked Questions

Should I use a Bluetooth SIG standard service or a fully custom one?
Use a standard SIG-assigned service (16-bit UUID) when your device's function genuinely matches an existing definition — Battery Service, Device Information Service, Heart Rate Service, and similar are worth adopting because many host platforms and apps already understand them natively, reducing companion app development effort. Use a fully custom service (128-bit UUID) whenever your data model doesn't cleanly match a standard service, which is the common case for purpose-built IoT sensors and controllers — forcing custom data into a mismatched standard service's semantics to avoid defining a custom UUID usually causes more integration friction than it saves.
What's the actual difference between Notify and Indicate in practice?
Both push data from the peripheral to a subscribed client without the client polling, but Indicate requires the client to send an acknowledgement (a Handle Value Confirmation) before the peripheral can send the next indication, while Notify does not wait for any acknowledgement. This makes Notify lower-latency and higher-throughput — appropriate for streaming sensor readings where an occasional dropped update is acceptable — while Indicate guarantees the peripheral knows the client actually received the value, appropriate for commands, alerts, or state changes where delivery confirmation matters more than raw throughput.
Why does a client need to write to the CCCD before notifications start working?
The Client Characteristic Configuration Descriptor (CCCD) is a per-connection, per-characteristic switch that the client must explicitly write to enable notifications or indications on that characteristic — a peripheral does not send unsolicited notifications to a client that hasn't opted in. This is a deliberate protocol design (giving the client control over which data streams it wants to receive) but is also the most common cause of 'notifications don't work' bugs: many mobile BLE libraries and some firmware stacks don't automatically re-write the CCCD after a reconnection, so the peripheral's internal notify-enabled flag resets and the client believes it's still subscribed when it isn't.

References

Related Questions

Related Forum Discussions