Skip to content
114 changes: 114 additions & 0 deletions index.bs
Original file line number Diff line number Diff line change
Expand Up @@ -3476,6 +3476,7 @@ peripheral's service.
readonly attribute UUID uuid;
readonly attribute BluetoothCharacteristicProperties properties;
readonly attribute DataView? value;
readonly attribute unsigned short maxWriteWithoutResponseSize;
Promise<BluetoothRemoteGATTDescriptor> getDescriptor(BluetoothDescriptorUUID descriptor);
Promise<sequence<BluetoothRemoteGATTDescriptor>>
getDescriptors(optional BluetoothDescriptorUUID descriptor);
Expand Down Expand Up @@ -3503,6 +3504,24 @@ Heart Rate Measurement</a> characteristic.
<dfn>value</dfn> is the currently cached characteristic value. This value gets
updated when the value of the characteristic is read or updated via a
notification or indication.

<dfn>maxWriteWithoutResponseSize</dfn> is the largest [=byte sequence=], in
octets, that {{BluetoothRemoteGATTCharacteristic/writeValueWithoutResponse()}}
can send to the peer in a single ATT PDU without it being fragmented or
rejected. It is derived from the ATT_MTU negotiated for the connection that
carries this characteristic.

Note: This value can change while the device is connected, for example because
an [=Exchange MTU=] procedure completes after service discovery. On some
platforms an updated value can arrive late, after services are resolved.
Whenever the UA observes that the ATT_MTU has changed it updates this value and
[=fires an event=] named {{maxwritewithoutresponsesizechanged}}, so the two stay
in sync: the value never changes without the event firing. Some platforms do
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.
Comment on lines +3520 to +3521

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?

Applications that batch large transfers can listen for
{{maxwritewithoutresponsesizechanged}} to react to an increase instead of
re-reading the attribute on every write.
</div>

Instances of {{BluetoothRemoteGATTCharacteristic}} are created with the
Expand All @@ -3522,6 +3541,20 @@ Instances of {{BluetoothRemoteGATTCharacteristic}} are created with the
Characteristic has been removed or otherwise invalidated.
</td>
</tr>
<tr>
<td><dfn>\[[maxWriteWithoutResponseSize]]</dfn></td>
<td><code>20</code></td>
<td>
The value, in octets, exposed by
{{BluetoothRemoteGATTCharacteristic/maxWriteWithoutResponseSize}}. It is
established when the {{[[representedCharacteristic]]}} is discovered and
updated only when the UA learns of an ATT_MTU change for the
[=ATT Bearer=] that carries it, at which point the
{{maxwritewithoutresponsesizechanged}} event is fired. The initial value
of <code>20</code> corresponds to the default ATT_MTU of 23 octets minus
the 3-octet ATT Write Command header.
</td>
</tr>
<tr>
<td><dfn>\[[automatedCharacteristicReadResponse]]</dfn></td>
<td><code>"not-expected"</code></td>
Expand Down Expand Up @@ -3624,6 +3657,22 @@ getDescriptors(<var>descriptor</var>)</dfn></code> method retrieves a list of
> <i>child type</i>="GATT Descriptor")</span>
</div>

<div algorithm="BluetoothRemoteGATTCharacteristic maxWriteWithoutResponseSize">
The {{BluetoothRemoteGATTCharacteristic/maxWriteWithoutResponseSize}} getter
steps are:

1. Return [=this=].{{[[maxWriteWithoutResponseSize]]}}.

<div class="note">
Note: The stored value is the ATT_MTU negotiated for the [=ATT Bearer=] that
carries the characteristic, minus 3 octets. The 3 octets account for the ATT
Write Command header (1 octet opcode and 2 octet attribute handle), leaving the
space available for the attribute value in a single PDU. The value is
established when the characteristic is discovered and only changes when the UA
observes an ATT_MTU change; see [[#mtu-change-events]].
</div>
</div>

<div algorithm="BluetoothRemoteGATTCharacteristic readValue()">
The <code><dfn method for="BluetoothRemoteGATTCharacteristic">readValue()</dfn>
</code> method, when invoked, MUST run the following steps:
Expand Down Expand Up @@ -4362,6 +4411,21 @@ interface <a>participate in a tree</a>.
<a href="#disconnection-events">an active GATT connection is lost</a>.
</dd>

<dt>
<dfn event for="BluetoothRemoteGATTCharacteristic">
<code>maxwritewithoutresponsesizechanged</code>
</dfn>
</dt>
<dd>
Fired on a {{BluetoothRemoteGATTCharacteristic}} when its
{{BluetoothRemoteGATTCharacteristic/maxWriteWithoutResponseSize}}
<a href="#mtu-change-events">changes</a>, for example after an
[=Exchange MTU=] procedure renegotiates the ATT_MTU for the connection.
The UA fires this event whenever it updates the value, so platforms that
never surface ATT_MTU changes (and therefore never change the value) also
never fire this event.
</dd>

<dt>
<dfn event for="BluetoothRemoteGATTService"><code>serviceadded</code></dfn>
</dt>
Expand Down Expand Up @@ -4483,6 +4547,51 @@ it must perform the following steps:

</div>

### Responding to MTU Changes ### {#mtu-change-events}

<div algorithm="mtu changes">
When the ATT_MTU negotiated for an [=ATT Bearer=] changes, for example because
the UA receives the result of an [=Exchange MTU=] procedure, the UA must
perform the following steps for each <a>Characteristic</a>
<var>characteristic</var> carried by that [=ATT Bearer=]:

Note: The UA only runs these steps when it observes an ATT_MTU change. Updating
the stored value and firing the {{maxwritewithoutresponsesizechanged}} event
happen together, so the value never changes without the event firing. Platforms
that do not surface ATT_MTU changes to the UA never run these steps, so on those
platforms the value stays constant after it is first established and the event
never fires.

1. Let <var>newSize</var> be the ATT_MTU now negotiated for that
[=ATT Bearer=] &minus; 3. This is the smaller of the local Host's transmit
ATT_MTU and the peer's receive ATT_MTU, less the 3-octet ATT Write Command
header.
1. For each |bluetoothGlobal| whose [=Bluetooth tree=] contains a
{{BluetoothRemoteGATTCharacteristic}} representing <var>characteristic</var>,
[=queue a global task=] on the [=Bluetooth task source=] given
|bluetoothGlobal|'s [=relevant global object=] to do the following
sub-steps:
1. Let <var>characteristicObject</var> be the
{{BluetoothRemoteGATTCharacteristic}} in the <a>Bluetooth tree</a>
rooted at <var>bluetoothGlobal</var> that represents
<var>characteristic</var>.
1. If <code><var>characteristicObject</var>
.service.device.gatt.{{BluetoothRemoteGATTServer/connected}}</code>
is `false`, abort these sub-steps.
1. If <var>characteristicObject</var>.{{[[maxWriteWithoutResponseSize]]}}
equals <var>newSize</var>, abort these sub-steps.
1. Set <var>characteristicObject</var>.{{[[maxWriteWithoutResponseSize]]}}
to <var>newSize</var>.
1. <a>Fire an event</a> named {{maxwritewithoutresponsesizechanged}} with
its <code>bubbles</code> attribute initialized to <code>true</code> at
<var>characteristicObject</var>.

Note: The new value is not carried on the event. Listeners read
<var>characteristicObject</var>.{{BluetoothRemoteGATTCharacteristic/maxWriteWithoutResponseSize}}
to obtain the current value.

</div>

<div class="unstable">
### Responding to Service Changes ### {#service-change-events}

Expand Down Expand Up @@ -4673,13 +4782,18 @@ Changed characteristic, it MUST perform the following steps.
[SecureContext]
interface mixin CharacteristicEventHandlers {
attribute EventHandler oncharacteristicvaluechanged;
attribute EventHandler onmaxwritewithoutresponsesizechanged;
};
</xmp>

<dfn attribute for="CharacteristicEventHandlers">
oncharacteristicvaluechanged</dfn> is an <a>Event handler IDL attribute</a> for
the {{characteristicvaluechanged}} event type.

<dfn attribute for="CharacteristicEventHandlers">
onmaxwritewithoutresponsesizechanged</dfn> is an <a>Event handler IDL
attribute</a> for the {{maxwritewithoutresponsesizechanged}} event type.

<xmp class="idl">
[SecureContext]
interface mixin BluetoothDeviceEventHandlers {
Expand Down