BLE GATT Profile Design Explained: Services, Characteristics, and the Power Budget Trade-offs Engineers Get Wrong
- Srihari Maddula
- 11 minutes ago
- 7 min read
Srihari Maddula • Founder & Technical Lead, Eurth Techtronics Pvt Ltd
Category: Firmware Architecture & RTOS
Estimated Reading Time: 9 min
A team building their first custom BLE product almost always makes the same early mistake: designing the GATT profile as a data model first — what fields does the device need to expose — and treating the power and timing consequences of that model as an afterthought to optimize later. By the time "later" arrives, the profile is baked into a mobile app's integration code and a certification submission, and the actual fix (restructuring which data lives in which characteristic, and how often each gets touched) is a much bigger change than it would have been at the design stage. This post covers GATT profile design as what it actually is: a power and timing architecture decision wearing a data-modeling costume.

Overview: What GATT Actually Governs
The Generic Attribute Profile (GATT) defines how a BLE peripheral exposes data to a central device (typically a phone): as a hierarchy of Services, each containing Characteristics, each of which can be read, written, or subscribed to for notifications. This structure is not just an API surface — every characteristic read, write, and especially every notification has a direct radio-airtime cost, and radio airtime is, for a battery-powered peripheral, the single most expensive thing the device does. A GATT profile designed purely around what data the app needs, without regard for how often each characteristic will actually be touched and by what mechanism, routinely produces a working integration with meaningfully worse battery life than a profile designed with the traffic pattern in view from the start.
Technical Details & Specifications: Services, Characteristics, and Their Real Costs
A minimal, well-structured custom profile — using the same real device from our BLE wearable work as the reference — defines one custom service with a handful of purpose-specific characteristics, each with deliberately chosen properties (read, write, notify) rather than defaulting every characteristic to support all three.
// GATT service and characteristic UUIDs -- 16-bit custom UUIDs
// used here for brevity in this internal service range; a real
// product should use a full 128-bit UUID to avoid collision with
// other vendors' custom services
#define SVC_UUID 0xFFF0 // Custom adherence-monitor service
#define CHAR_EVENT_DATA_UUID 0xFFF1 // Notify only -- new gesture events
#define CHAR_STATUS_UUID 0xFFF2 // Read + notify -- battery, device state
#define CHAR_CONFIG_UUID 0xFFF3 // Read + write -- detection thresholds
#define CHAR_CRASHLOG_UUID 0xFFF4 // Read only -- diagnostic data, rarely accessed
// Characteristic properties matter as much as their data content --
// each property maps to a different traffic pattern and power cost
static const att_db_desc_t char_table[] = {
{ CHAR_EVENT_DATA_UUID, ATT_PROP_NOTIFY, 12 }, // 12-byte event struct
{ CHAR_STATUS_UUID, ATT_PROP_READ | ATT_PROP_NOTIFY, 4 },
{ CHAR_CONFIG_UUID, ATT_PROP_READ | ATT_PROP_WRITE, 8 },
{ CHAR_CRASHLOG_UUID, ATT_PROP_READ, 64 }, // large, but rarely read
};
GATT operation | Radio cost | Typical use pattern |
Read (central-initiated) | One request/response exchange, on demand | Config values, occasional status checks |
Write (central-initiated) | One request/response exchange, on demand | Config changes, commands |
Notify (peripheral-initiated) | Sent proactively whenever the value changes — cost scales with change frequency, not app polling | Event streams, frequently-changing status |
Indicate | Like notify, but with mandatory acknowledgment — higher reliability, higher airtime cost per update | Critical data where delivery confirmation matters more than airtime cost |
The distinction between notify and a polled read is the single highest-leverage GATT design decision for battery life. A characteristic the app reads by polling every few seconds costs radio airtime on every poll regardless of whether the value actually changed. A characteristic set up for notification only sends data when the value genuinely changes, and — critically — the connection can run at a much longer connection interval when nothing needs to be sent, waking only when there's an actual notification to deliver. A GATT design that defaults every characteristic to "read + notify, app decides how to use it" pushes a power-relevant decision onto the app team, who frequently don't have visibility into the firmware-side power cost of their polling choice.
THE RULE: Every characteristic set to support polling as well as notification is an invitation for the app team to poll, because polling is simpler to implement than subscription logic — and polling is almost always the more power-expensive choice for the firmware side. Design the properties to make the power-cheap choice the only easy one.
Advantages: What a Deliberately-Scoped Profile Buys You
A minimal, purpose-scoped profile — few characteristics, each with tightly-matched properties to its actual traffic pattern — has three concrete advantages over a broad, permissive one. First, and most directly, lower average radio-on time, because notify-only characteristics only transmit on genuine state change rather than on app-driven polling cadence. Second, a smaller attack surface for BLE security review — every readable/writable characteristic is a potential point of unauthorized access if pairing/bonding isn't correctly enforced, and a profile with fewer, more tightly-scoped characteristics is genuinely easier to audit and get right. Third, and easy to underweight during initial design: a smaller, well-documented profile is meaningfully easier for a third-party app developer (or a future engineer on your own team) to integrate against correctly, because there's less ambiguity about which characteristic to use for which purpose.
Challenges & Trade-offs: Where Minimal Profiles Cause Their Own Problems
A profile that's too minimal creates its own failure mode: if event data, status, and configuration are all crammed into too few characteristics to save on GATT table overhead, the app side ends up parsing multi-purpose payloads with internal type tags — effectively reinventing a sub-protocol inside a single characteristic, which pushes complexity into application-layer parsing logic that GATT's structure was meant to avoid. There's a real balance point between "too many characteristics, each touched inefficiently" and "too few characteristics, each overloaded with mixed-purpose data," and it's found by grouping data specifically by update frequency and consumer, not by data type alone — two pieces of data that change at wildly different rates don't belong in the same characteristic even if they're conceptually related, because the higher-frequency field will drag unnecessary notification traffic for the lower-frequency one bundled alongside it.
Connection parameter negotiation is the other genuine challenge: the peripheral can request a preferred connection interval, but the central device (the phone's OS) has final say, and phone OS BLE stacks (iOS and Android both) apply their own policies and constraints that can override a peripheral's power-optimal request — an interval that tested well against a specific Android version's BLE stack in the lab can behave differently against iOS, or against a different Android OEM's modified BLE stack in the field. This is a genuinely hard-to-fully-control variable, and the practical mitigation is testing against the actual range of target devices early, not assuming a single reference phone's behavior generalizes to the full target device population.
THE RULE: The phone's OS, not your firmware, has final authority over the actual connection interval used. Design for a wide acceptable range and test against real target-device diversity — a single reference phone's behavior does not generalize.
Case Study: Restructuring a Profile Mid-Development
An early draft of the profile referenced above put battery percentage, device state, and firmware version into a single "status" characteristic that the app polled every 30 seconds — a reasonable-looking design choice that treated all three as "device status information," conceptually related and therefore, it seemed, naturally grouped. In practice, firmware version essentially never changes after initial pairing, device state changes only on specific events (charging start/stop, error conditions), and battery percentage changes gradually but continuously. Polling all three together every 30 seconds meant paying full radio-airtime cost for firmware version and device state on every single poll, for data that was, the overwhelming majority of the time, identical to the previous read.
The fix, made during a mid-development profile revision (costly enough to require app-side integration changes, but far cheaper than a post-certification change would have been), split this into a notify-only battery characteristic (updates only on meaningful percentage change, not on a timer) and a separate, rarely-touched device-info characteristic covering firmware version and static identifiers, read once at connection time rather than polled. Device state moved to notify-only as well, firing only on actual state transitions. The measured result was a meaningful reduction in average connected-mode radio-on time, without any change to what data the app actually had access to — purely a restructuring of how and when that data moved across the radio link. The broader lesson: grouping GATT data by conceptual category rather than by update frequency is an intuitive mistake, and it's specifically the kind of mistake that doesn't show up as a bug — the profile works correctly, it's just quietly more expensive than it needs to be, which is exactly why it's easy to ship without noticing.
Implementation Plan: Designing a GATT Profile Deliberately
Before defining any characteristic, list every piece of data the device needs to expose along with its actual update frequency — continuous, event-driven, or effectively static — and group characteristics by that frequency, not by conceptual category.
For every characteristic, choose properties (read/write/notify/indicate) that match its actual access pattern — don't default to enabling everything 'in case the app needs it'; that default pushes power-relevant decisions onto app developers who can't see the firmware-side cost.
Keep payload sizes tight and purpose-built per characteristic — a compact, well-defined struct (as in the event-data example above) beats a generic, larger payload with unused fields, since every notification's airtime cost scales with payload size.
Design connection parameter requests for a realistic acceptable range, and test against actual target-device diversity (multiple Android OEMs, multiple iOS versions) rather than a single reference device, since the OS ultimately controls the negotiated interval.
Document the profile's intended traffic pattern alongside its UUID table — which characteristics are meant to be polled occasionally versus subscribed to continuously — so a future app integration doesn't accidentally reintroduce a polling pattern the profile was specifically designed to avoid.
Revisit the profile explicitly once real usage telemetry exists — actual connected-mode radio-on time measured in the field is the ground truth a design-stage estimate can't fully replace, and a profile that looked well-structured on paper may still reveal an unexpected hot characteristic once real traffic patterns are visible.
Conclusion: GATT Design Is Power Architecture, Not Data Modeling
The recurring theme across every section here is the same one: a GATT profile's structure isn't a neutral data-modeling exercise that happens to run over a radio — it's a direct, load-bearing determinant of the device's power consumption and connection behavior, and treating it as an afterthought to a data model designed first is how a working BLE integration ends up quietly more power-hungry than it needed to be. The teams that get this right design the profile with update frequency and traffic pattern as first-class inputs from the start, choose characteristic properties deliberately rather than permissively, and revisit the design against real field telemetry rather than assuming the design-stage estimate was the final word — the same discipline that shows up everywhere else in battery-powered embedded design, applied specifically to the one layer that's easiest to treat as "just an API" until the battery-life numbers come back worse than expected.
EurthTech delivers AI-powered embedded systems, IoT product engineering, and smart infrastructure solutions — Hyderabad, India. www.eurthtech.com




Comments