Skip to content

Add BluetoothRemoteGATTCharacteristic.maxWriteWithoutResponseSize#672

Open
hjanuschka wants to merge 8 commits into
WebBluetoothCG:mainfrom
hjanuschka:add-characteristic-getmtu
Open

Add BluetoothRemoteGATTCharacteristic.maxWriteWithoutResponseSize#672
hjanuschka wants to merge 8 commits into
WebBluetoothCG:mainfrom
hjanuschka:add-characteristic-getmtu

Conversation

@hjanuschka

@hjanuschka hjanuschka commented Jun 18, 2026

Copy link
Copy Markdown

Hi folks 👋

I've been working on the Chromium implementation for exposing the
negotiated ATT MTU to web pages (crrev.com/c/7879985)

What this adds

A new synchronous maxWriteWithoutResponseSize attribute on
BluetoothRemoteGATTCharacteristic:

readonly attribute unsigned short maxWriteWithoutResponseSize;

It returns the largest byte sequence, in octets, that a single
writeValueWithoutResponse() can send without being rejected. It is
derived from the ATT_MTU negotiated for the connection that carries the
characteristic (the smaller of the local Host's transmit ATT_MTU and the
peer's receive ATT_MTU), minus the 3-octet ATT Write Command header. When
no MTU exchange has happened it's 23 - 3 = 20 octets.

The attribute is evaluated at read time, so there's no cached value in the
spec object: the next read after an MTU change returns the new value.

I also added a maxwritewithoutresponsesizechanged event (bubbles, like
characteristicvaluechanged) plus the
onmaxwritewithoutresponsesizechanged handler, so apps that batch large
transfers can react to an increase instead of polling. The event
deliberately doesn't carry the value; listeners read the attribute, keeping
a single source of truth.

Why I want this

Today there's no way for a site to know how big a single write can be. The
practical pain is writeValueWithoutResponse(): if you hand it more than
ATT_MTU - 3 bytes, the write either fails or gets silently chopped
depending on the platform, and the page has no way to tell ahead of time.
People end up hard-coding 20 (the old default payload) to be safe, which
leaves a lot of throughput on the table on modern devices that happily
negotiate 247+.

Exposing the max write size lets sites chunk their writes to fit a single
PDU and avoid unnecessary round trips. This has been asked for a few times
over the years (40265040 / 40686244 / 40163619 on the Chromium side).

Design notes

  • Returning ATT_MTU - 3 (inspired by CoreBluetooth's
    maximumWriteValueLengthForType:) rather than the raw MTU means callers
    don't have to know to subtract the ATT Write Command header themselves.
  • This lives on the characteristic rather than BluetoothRemoteGATTServer
    because BlueZ tracks MTU per characteristic, and other OSes could add
    per-characteristic MTU support in the future.
  • Open: CoreBluetooth has no delegate callback for MTU changes, so the
    maxwritewithoutresponsesizechanged event may never fire on macOS.
    Still deciding whether to keep it as best-effort or drop it in favor of
    "read the attribute every time you need it".

Preview | Diff

Expose the negotiated ATT_MTU for the connection carrying a
characteristic via a new getMTU() method, returning the smaller of the
local Host's transmit ATT_MTU and the peer's receive ATT_MTU as
established by the Exchange MTU procedure.

Knowing the MTU lets sites size writeValueWithoutResponse() payloads to
fit a single PDU and avoid fragmentation.
@dlech

dlech commented Jun 18, 2026

Copy link
Copy Markdown
Contributor
  • I put this on the characteristic to match where reads/writes live

I think it has to be there because of the way BlueZ implements it. In theory, other OSes could add support for per-characteristic MTU in the future as well.

  • Naming: getMTU() vs an mtu attribute.

Since the actual useful information we want is "what is the max size can I use for write without response", I would use a name inspired from CoreBluetooth instead, e.g. maxWriteWithoutResponseSize. This would return MTU - 3. This way all users don't have to know to subtract 3 to get the actual value that is useful.

Since most OSes don't actually perform any I/O to get the number (they just return whatever number they currently know), making it a promise seems misleading.

I also know from experience with maintaining the Bleak Python library that MTU changes can come late (after connecting and resolving services), particularly on Windows. So it could actually make more sense to make this an event rather than an attribute. Or we just need to document that the value can change, so it should be read every time that it is used rather than caching it in some variable in the user application.

Address review feedback: rename getMTU() to a synchronous
maxWriteWithoutResponseSize attribute (inspired by CoreBluetooth) that
returns ATT_MTU - 3, the largest payload writeValueWithoutResponse() can
send in a single PDU. Since platforms return a cached value without
performing I/O, a plain attribute is more honest than a promise. Document
that the value can change while connected and should be read each time
rather than cached.
Pair the live maxWriteWithoutResponseSize attribute with an event so
applications can either read the current value at write time or react to
changes pushed by the UA (e.g. a late Exchange MTU renegotiation). The
event does not carry the value; listeners read the attribute, keeping a
single source of truth. Add the onmaxwritewithoutresponsesizechanged
handler to CharacteristicEventHandlers and a "Responding to MTU Changes"
algorithm that fires the event.
@hjanuschka

Copy link
Copy Markdown
Author

Thanks, pushed new commit.

  1. Renamed getMTU() to a synchronous maxWriteWithoutResponseSize attribute. It returns ATT_MTU - 3 so callers don't have to know to subtract the ATT Write Command header themselves.

  2. Made it a getter that's evaluated at read time, so mutability falls out for free: there's no cached value in the spec object, and the next read after an MTU change returns the new value.

  3. On top of that I added a maxwritewithoutresponsesizechanged event (bubbles, like characteristicvaluechanged) plus the onmaxwritewithoutresponsesizechanged handler, so apps that batch large transfers can react to an increase instead of polling. The event deliberately doesn't carry the value; listeners read the attribute, keeping a single source of truth.

LMKWYT

Reference the attribute from its getter-steps block instead of defining a
second <dfn>, which removes the duplicate id and the conflicting local
attribute definition. Drop the unresolved "service discovery" autolink and
move the trailing note out of the list so the closing tag parses as valid
Markdown.

@reillyeon reillyeon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like generally the right solution. Will follow up on the proposed Chromium change to make sure this is implementable.

Comment thread index.bs Outdated
@reillyeon

Copy link
Copy Markdown
Contributor

Please update the PR title and description to match the latest revision.

@dlech

dlech commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Will follow up on the proposed Chromium change to make sure this is implementable.

I forgot about this when I suggested an event, but CoreBluetooth doesn't have a delegate callback to be notified when the MTU changes. So it probably doesn't make sense to have an event as part of the API (unless we can be sure that maximumWriteValueLengthForType: always returns the final value and the event just would not ever fire on macOS).

Introduce a "characteristic" variable in the "for each Characteristic"
clause and reference it in the sub-steps instead of repeating
"the Characteristic", per review feedback.
@hjanuschka hjanuschka changed the title Add BluetoothRemoteGATTCharacteristic.getMTU() Add BluetoothRemoteGATTCharacteristic.maxWriteWithoutResponseSize Jun 23, 2026
Some platforms (notably CoreBluetooth) do not surface ATT_MTU changes to
the UA, so the event cannot fire there. Document it as a best-effort hint
to re-read maxWriteWithoutResponseSize rather than an authoritative or
guaranteed signal, and tell applications not to rely on it as the only
way to observe the value.
@hjanuschka

Copy link
Copy Markdown
Author

Will follow up on the proposed Chromium change to make sure this is implementable.

I forgot about this when I suggested an event, but CoreBluetooth doesn't have a delegate callback to be notified when the MTU changes. So it probably doesn't make sense to have an event as part of the API (unless we can be sure that maximumWriteValueLengthForType: always returns the final value and the event just would not ever fire on macOS).

Rather than drop it, I've specced the event as best-effort: it MAY fire when the UA learns of an MTU change, but isn't guaranteed on platforms like CoreBluetooth that don't surface those changes. that keeps the event usable on platforms that support it. hope that is ok for you!

Comment thread index.bs Outdated
Comment on lines +4546 to +4549
best-effort basis. Some platforms do not notify the UA when the ATT_MTU
changes, in which case the event does not fire even though
{{BluetoothRemoteGATTCharacteristic/maxWriteWithoutResponseSize}} may return a
different value on a subsequent read. The event is therefore a hint to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't how you've implemented it in Chromium. I think if the implementation is capable of noticing changes and returning a different value it could fire an event. Otherwise if the platform doesn't provide a notification then the property also would never change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getter is now defined to return the current ATT_MTU at read time, so a fresh read always reflects the present value; the maxwritewithoutresponsesizechanged event is just a convenience notification fired when the UA observes a change, not the only way the value updates.

That lines up with the Chromium CL: the value is seeded from the live MTU at characteristic enumeration and refreshed by the event on BlueZ/Android/WinRT. its not 100% "fresh" but the caching is what i think implementational detail, as the developer observed value always represents the real value.

macOS is the one platform with no MTU-change callback, but CoreBluetooth settles the ATT_MTU at connection time (before characteristics are discovered) and never renegotiates, so the value there is final from the first read and never goes stale.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My issue with the current text is that it implies that on platforms where there is no MTU change event the value will be read live rather than being cached and could change even without a maxwritewithoutresponsesizechanged event. This isn't how it is implemented.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. I'll flip the spec to the coupled model: the value updates together with the maxwritewithoutresponsesizechanged event when the UA observes an MTU change, and on platforms with no change notification the value just stays constant after it's first established. That matches the cached implementation, the value never changes without the event firing.

Drop the cached internal-slot model in favor of an on-demand getter that
reflects the ATT_MTU at read time. This way a fresh read returns the
current value even on platforms (such as CoreBluetooth) that do not
surface ATT_MTU changes via the maxwritewithoutresponsesizechanged event.
The event remains a convenience notification fired when the UA observes a
change; the attribute is the authoritative source.
Restore a stored value that is established at characteristic discovery
and only updated when the UA observes an ATT_MTU change, at which point
the maxwritewithoutresponsesizechanged event fires. This matches the
cached Chromium implementation: the value never changes without the
event firing, and platforms with no MTU-change notification (CoreBluetooth)
keep a constant value and never fire the event. Replaces the earlier
live-read wording that implied the value could change without an event.
Comment thread index.bs
Comment on lines +3520 to +3521
not surface ATT_MTU changes to the UA at all; on those platforms the value
stays constant after it is first established and the event never fires.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we really be sure of this? Bluetooth 5.2 allows the MTU to change later. And since we can't see Apple's source code, we don't know if they handle this or not.

This is why I am having second thoughts about having an event. If it doesn't work on all platforms, then it can't be relied upon. And if it can't be relied upon, then what is the point of having it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the platform will change its answer without firing an event and the browser is caching the value then it doesn't matter if the platform silently changes it because the browser won't notice. I think this is fine, and if the browser does check again for some reason then it could fire an event and update the value.

The key thing is that unless we go back to the asynchronous getMTU() method, we can't guarantee that the attribute will reflect the latest value unless the OS provides an event. At best, when the site accesses the attribute we could trigger a check in the background and fire an event if we discover a new value.

@hjanuschka hjanuschka Jun 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At best, when the site accesses the attribute we could trigger a check in the background and fire an event if we discover a new value.

that is meh and smart at the same time - to close the platform gap, i love it!

as you both have maybe figured out, i am pretty new to spec's (in general), not sure how to proceed now!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect the implementation to either not cache the value if a synchronous getter is available on the OS. Or cache the value and always be subscribed to changes if there isn't such a getter.

In other words, I would expect reading the maxWriteWithoutResponseSize attribute in the browser would synchronously call GattSession.MaxPduSize on WinRT and maximumWriteValueLengthForType(CBCharacteristicWriteWithoutResponse) on CoreBluetooth.

For BlueZ, I would expect that we are already subscribed to D-Bus property changes on characteristics so should always have the latest value without having to do an async D-Bus call. Similarly, on Android, I would expect that we would always be subscribed to onMtuChanged so that we always have the latest value available.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still not sure if i am fully understanding the concerns, the described "never cache and also synch" is a potential blocker for the browser, from my point of view.

all platforms have events, that totally solve the issue, except one, wouldn't it be better to fix that one platform?
(or use @reillyeon approach of synthetic events?)

anyway, i am following whatever you as experts resolve to, its just my 2cents, and i am trying to move the implementation forward.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the event as a Windows-only workaround, though. The attribute is the simple path for apps that just want a size at the time they start writing. The event is the complementary path for apps that want to react if the UA later learns a better current size. Windows is one concrete case where that can happen (thats where i am comming from, trying to fix the limitation on windows), but the web-facing model does not have to expose that as a Windows quirk.

From the developer point of view the API stays simple: read the synchronous attribute; optionally listen for maxwritewithoutresponsesizechanged if you want to re-chunk/retry when the usable size improves. For example, your snippet could become:

async function writeFirmwareBlob(characteristic, array) {
  let chunkSize = characteristic.maxWriteWithoutResponseSize;

  characteristic.addEventListener("maxwritewithoutresponsesizechanged", () => {
    chunkSize = characteristic.maxWriteWithoutResponseSize;
  });

  for (let i = 0; i < array.length; ) {
    const chunk = array.slice(i, i + chunkSize);
    await characteristic.writeValueWithoutResponse(chunk);
    i += chunk.byteLength;

    // Some delay or back-pressure check here.
    // If the MTU improves while this is running, the next iteration uses the
    // new chunk size.
  }
}

Platforms like CoreBluetooth that do not report later changes can simply never fire it. Platforms that do report changes can fire it natively.

That also fits Chromium's architecture: Blink can expose the synchronous attribute from renderer-held state, while the browser process updates that state asynchronously when it observes a change. So we avoid blocking the renderer while still giving developers one consistent API surface.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, even without listening for the event, apps can avoid holding a stale local copy by reading the attribute at the point of use instead of copying it once:

for (let i = 0; i < array.byteLength; ) {
  const chunkSize = characteristic.maxWriteWithoutResponseSize;
  const chunk = array.slice(i, i + chunkSize);
  await characteristic.writeValueWithoutResponse(chunk);
  i += chunk.byteLength;
}

Whether Chromium implements that synchronous attribute from renderer-held state or another UA reads it directly from its platform backend is an implementation detail. The web-observable contract is just that the getter returns the UA's current known maximum.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This second example is what makes me think we don't really need an event. Unless there are other use cases?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, but, i initially came from the issue that e.g windows having issues.
still noob here on the spec side, not sure what is required for spec, and what is implementational-freedom of the UA :/

@reillyeon guess we could, on chromium side, block till windows has working mtu (eventhough it might block)? and go from there?

thank you both for the time and help!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any ideas how to proceed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants