PSA: Messaging API Changes for Chromium Extension Developers

556 views
Skip to first unread message

Justin Lulejian

unread,
Nov 5, 2025, 6:04:43 PMNov 5
to Chromium Extensions, Oliver Dunk

Dear Chromium Extension Developers,


We're excited to announce several related upcoming changes in Chrome 144. These are currently available in the latest Canary. The change should reach Stable on January 13th, 2026.


TL;DR

  • Launching the browser namespace by default, as an optional alternative to chrome

  • Supporting Promise returns in runtime.onMessage listeners

  • Changes to the messaging APIs, designed to better align with webextension-polyfill (polyfill)


These improvements aim to make extension development more consistent and predictable across different browsers. They also enable the browser namespace we announced earlier, which will launch with these changes.

We strongly encourage you to test these new features to see if your extension(s) require any messaging adjustments since the polyfill will become a noop when these changes take effect.


Supporting Promise returns from runtime.OnMessage listeners

We are implementing support for returning promises directly from runtime.OnMessage listeners. With this and promise support now available for other APIs, we hope most extension developers will no longer need the polyfill.


// content_script.js

let response = await chrome.runtime.sendMessage('test');

console.assert(response === 'promise resolved');


// background.js

function onMessageListener(message, sender, sendResponse) {

  return new Promise((resolve) => {

    resolve('promise resolved');

  }

}

chrome.runtime.onMessage.addListener(onMessageListener);


runtime.OnMessage Behavior Alignment with Polyfill

In addition to promise support, we are making adjustments to how runtime.OnMessage() behaves with error scenarios to more closely match the polyfill.


Listeners that throw errors before responding 

The first listener to throw an error will cause the sender’s promise to reject. 


// content_script.js

chrome.runtime.sendMessage('test').reject((reject) => {

  console.assert(reject.message === 'error!');

});


// background.js

function onMessageListener(message, sender, sendResponse) {

  throw new Error('error!');

}

chrome.runtime.onMessage.addListener(onMessageListener);


function onMessageListener(message, sender, sendResponse) {

  sendResponse('response');

}

chrome.runtime.onMessage.addListener(onMessageListener);


Listener ordering still matters: 

// content_script.js

let response = await chrome.runtime.sendMessage('test');

console.assert(response === 'response');


// background.js

function onMessageListener(message, sender, sendResponse) {

  sendResponse('response');

}

chrome.runtime.onMessage.addListener(onMessageListener);


function onMessageListener(message, sender, sendResponse) {

  throw new Error('error!');

}

chrome.runtime.onMessage.addListener(onMessageListener);


We’ve also made improvements to handling unserializable responses. See the next section for more details as it is a slightly different implementation.


Subtle differences from the polyfill and runtime.onMessage (MDN)

While we aim to closely match the polyfill and runtime.onMessage on MDN, there will still be some subtle differences. This section outlines all known differences to help you smoothly transition away from using the polyfill if it's no longer needed. If you find any significant additional ones please let us know!


Returned promises that reject with anything other than an Error-type (undefined, etc.)

They’ll receive a generic static error message, rather than the .message property from the thrown object which the polyfill would return.


// content_script.js
// Chromium

chrome.runtime.sendMessage('test').reject((reject_reason) => {

  console.assert(
    reject.message === 'A runtime.onMessage listener\'s promise rejected without an ' +

      'Error');

});


// background.js

function onMessageListener(message, sender, sendResponse) {

  return new Promise((unusedResolve, reject) => {

    reject(undefined);

  });

}

chrome.runtime.onMessage.addListener(onMessageListener);


Listeners that respond with unserializable responses

This now rejects the sender’s promise like the polyfill does, but it does it immediately and with a specific error rather than waiting until the worker stops.


// content_script.js

chrome.runtime.sendMessage('test').reject((reject_reason) => {

  console.assert(reject.message === 'Error: Could not serialize message.!');

});


// background.js

function onMessageListener(message, sender, sendResponse) {

  // Functions aren't currently serializable but we're working on it.

  sendResponse(() => {});  // a TypeError is thrown here

}

chrome.runtime.onMessage.addListener(onMessageListener);


async functions passed to runtime.OnMessage

async functions always return promises, so the browser treats them as if a promise was returned from the listener, even if it never responds or calls sendResponse. This is similar to how runtime.onMessage() as described on MDN handles async functions as sending an asynchronous response using a Promise. However, our response return values differ slightly:


// content_script.js

let response = await chrome.runtime.sendMessage('test');

console.assert(response === true);  // MDN

console.assert(response === null);  // Chromium


// background.js

async function onMessageListener(message, sender) {

  // Do some things, but without sendResponse

}


chrome.runtime.onMessage.addListener(onMessageListener);


multiple async functions passed to runtime.OnMessage

runtime.onMessage() MDN documentation describes that passing an async function to a listener will prevent any other listeners (async or not) from responding to the sender’s message. Chromium does not have this restriction. Multiple async or non-async functions can be added as runtime.onMessage() listeners. The first listener to respond (synchronously or asynchronously) will have its response sent back to the message sender.


// content_script.js

let response = await chrome.runtime.sendMessage('test');

console.assert(response === 'slower response');  // MDN

console.assert(response === 'faster response');  // Chromium


// background.js

async function onMessageSlowerRespondingListener() {

  return new Promise((resolve) => {

    setTimeout(resolve, 1000, 'slower response');

  });

}

chrome.runtime.onMessage.addListener(slowerAsyncFunctionResponse);


async function onMessageFasterRespondingListener(message, sender) {

  return 'faster response';

}

chrome.runtime.onMessage.addListener(onMessageListener);


We hope that this change will make it easier to develop extensions across multiple browsers.


If you have any questions, please feel free to reach out to us.


Thank you,

Justin, on behalf of the Chromium Extensions Team


Juraj M.

unread,
Nov 6, 2025, 4:05:11 AMNov 6
to Chromium Extensions, Justin Lulejian, Oliver Dunk
Amazing news, and you even implemented racing between replies from multiple listeners, that's crazy!
Although I'm not exactly sure how that works, since the "async" function always returns a promise, does this mean the first non-undefined reply wins the race?

Actually, checking it now with some experiments, it may not work as expected :), the first async handler will still consume all messages.

Let's try some experiments, let's say we have this code in the background script:
browser.runtime.onMessage.addListener(async (message, sender) => {
  switch (message.type) {
    case 'a': return 1;
    case 'b': return new Promise(r => setTimeout(() => r(2), 1e3));
  }
});
browser.runtime.onMessage.addListener(async (message, sender) => {
  switch (message.type) {
    case 'c': return 3;
    case 'd': return new Promise(r => setTimeout(() => r(4), 1e3));
  }
});

Now, let's send it 4 messages:
await browser.runtime.sendMessage({type: 'a'}) // returns 1
await browser.runtime.sendMessage({type: 'b'}) // returns null
await browser.runtime.sendMessage({type: 'c'}) // returns null
await browser.runtime.sendMessage({type: 'd'}) // returns null

This is exactly what I would expect - the "b" is null because the second handler already replies with Promise<undefined>, the "c" is null because the first handler replies with Promise<undefined> and same for the "d".
Also, why does it return null and not undefined? The reply is from the handler is obviously "undefined", not "null".

Anyway, if you want to make this work as expected, you still need to remove the async handlers:
browser.runtime.onMessage.addListener((message, sender) => {
  switch (message.type) {
    case 'a': return Promise.resolve(1);
    case 'b': return new Promise(r => setTimeout(() => r(2), 1e3));
  }
});
browser.runtime.onMessage.addListener((message, sender) => {
  switch (message.type) {
    case 'c': return Promise.resolve(3);
    case 'd': return new Promise(r => setTimeout(() => r(4), 1e3));
    case 'e': return Promise.resolve(undefined);
  }
});

Then you'll get:
await browser.runtime.sendMessage({type: 'a'}) // returns 1
await browser.runtime.sendMessage({type: 'b'}) // returns 2
await browser.runtime.sendMessage({type: 'c'}) // returns 3
await browser.runtime.sendMessage({type: 'd'}) // returns 4
await browser.runtime.sendMessage({type: 'e'}) // returns null 

Which is almost correct, except for the "e" which should return "undefined".
Actually, checking the old version with the polyfill active, it also returns null. But I think that's still incorrect, for example Firefox will return undefined.

In any case, testing my extensions in Chrome 144 now, it seems to work OK!
Thanks a lot for making this happen! :)

PS:
Talk about messaging... any chance to add support for sending Blobs? :)
It's one of the last compatibility issues I'm facing when implement extensions for Firefox/Chrome.

PhistucK

unread,
Nov 6, 2025, 2:05:16 PMNov 6
to Justin Lulejian, Chromium Extensions, Oliver Dunk

// content_script.js

chrome.runtime.sendMessage('test').reject((reject) => {

  console.assert(reject.message === 'error!');

});

// content_script.js

chrome.runtime.sendMessage('test').reject((reject_reason) => {

  console.assert(reject.message === 'Error: Could not serialize message.!');

});


Did you mean .catch(...)?

PhistucK


--
You received this message because you are subscribed to the Google Groups "Chromium Extensions" group.
To unsubscribe from this group and stop receiving emails from it, send an email to chromium-extens...@chromium.org.
To view this discussion visit https://groups.google.com/a/chromium.org/d/msgid/chromium-extensions/506b499d-c278-4408-9955-3cab48c73352n%40chromium.org.

Alexander Shutau

unread,
Nov 6, 2025, 3:30:57 PMNov 6
to Chromium Extensions, PhistucK, Chromium Extensions, Oliver Dunk, Justin Lulejian
Since Chrome 144, when chrome.runtime.onMessage has multiple listeners, one with a Promise and another with sendResponse and return true, the response received will be null.
I've submitted a bug report https://issues.chromium.org/issues/458410308

DreamBuilder Team

unread,
Nov 7, 2025, 12:15:36 PMNov 7
to Chromium Extensions, Alexander Shutau, PhistucK, Chromium Extensions, Oliver Dunk, Justin Lulejian
Good point @ Alexander, confirmed on my side - if code uses aproach below if fails with null response.
Sounds like sendResponse is not supported in new architecture.

Max Nikulin

unread,
Nov 10, 2025, 11:58:56 AMNov 10
to chromium-...@chromium.org
On 06/11/2025 16:05, Juraj M. wrote:
> *Amazing news, and you even implemented racing between replies from
> multiple listeners, that's crazy!*
> Although I'm not exactly sure how that works, since the "async" function
> always returns a promise, does this mean the first non-undefined reply
> wins the race?

After rereading the original post I have figured out that it is unclear
for me which way you inferred that Promise resolved to undefined should
be treated in some special way (besides a subtle point, see below)? Race
that discards undefined may be fragile. (Almost certainly it may be
implemented as a wrapper though.)

> Also, why does it return null and not undefined? The reply is from the
> handler is obviously "undefined", not "null".
[...]
>     case 'e': return Promise.resolve(undefined);[...]> await browser.runtime.sendMessage({type: 'e'}) // returns null
>
> Which is almost correct, except for the "e" which should return "undefined".
> Actually, checking the old version with the polyfill active, it also
> returns null. But I think that's still incorrect, for example Firefox
> will return undefined.
[...]> PS:
> Talk about messaging... any chance to add support for sending Blobs? :)

I think, the difference is that Firefox uses structured clone while
Chromium relies on JSON serialization. It seems, it may be changed soon:

<https://issues.chromium.org/40321352>
Extension messaging uses base::Value (JSON) serialization but could use
WebSerializedScriptValue (structured cloning)

There is no undefined in JSON, so it is impossible to distinguish null
and undefined in promise returned by sendMessage`. However Promise
resolved to undefined should be a special case since

JSON.parse(JSON.stringify(undefined))

throws an exception.

Juraj M.

unread,
Nov 10, 2025, 12:54:40 PMNov 10
to Chromium Extensions, Max Nikulin
Thank you Max for the explanation about the null/undefined, that makes perfect sense now! :)

About my first paragraph, I was just super confused (without checking your examples) how could you race two async handlers when one of them always wins with "undefined" while the other one tries to do something actually async, but will always reply second.
I hope you understand what I'm trying to say :).

My examples with the switch case is a simplified real world example - something I use in all of my extensions in multiple files.
I have many handlers, and each is handling a specific set of messages.
But if I made them all async, then the first handler would still consume all messages, because it would reply with "undefined" when it didn't handled the message.

So when writing new docs, you should definitely mention that :)

Justin Lulejian

unread,
Nov 10, 2025, 5:06:25 PMNov 10
to Chromium Extensions, Juraj M., Max Nikulin, Oliver Dunk

Hi all,

Thank you for all the observations and feedback; it's been very helpful!

@PhistucK: Yes, that was my mistake. Thank you for .catch()-ing that!

@Juraj + Alexander + DreamBuilder Team:

Multiple `async` function listeners (as well as synchronous and other asynchronous responses) are supported and they should race to respond to the sender. I responded to Alexander's bug report, which I believe explains the issue and why you're seeing this behavior. The key thing to keep in mind is that `async` functions always return a `Promise`, even if one doesn't explicitly return a value (which implicitly resolves the Promise to undefined).

AFAICT we're aligned with the Firefox behavior (minus the undefined -> null conversion that happens), so this is working as intended. I'll be documenting this with a specific example in the messaging docs to make this clearer for other developers. However, if after taking a look at my response in the bug report it seems like I'm misunderstanding the issue, please let me know!

Max is correct that the undefined -> null conversion is related to Chromium's JSON.stringify serialization of the messages. I'm currently working on finishing up the structured cloning implementation. Once that's done, `undefined` will be sent back to the message sender unmodified. It should also support Blobs, so I'm glad to hear that will be helpful.

Max Nikulin

unread,
Nov 11, 2025, 10:53:59 AMNov 11
to chromium-...@chromium.org
On 11/11/2025 05:06, Justin Lulejian wrote:
> Multiple `async` function listeners (as well as synchronous and other
> asynchronous responses) are supported and they should race to respond to
> the sender. I responded to Alexander's bug report
> <https://crbug.com/458410308>,> which I believe explains the issue and why you're
> seeing this behavior. The key thing to keep in mind is that `async`
> functions always return a `Promise`, even if one doesn't explicitly
> return a value (which implicitly resolves the Promise to undefined).

For the provided example your comment explains behavior. On the other hand

chrome.runtime.onMessage.addListener(async (message) => {
await new Promise(resolve => {
setTimeout(resolve);
});
});

looks a bit strange for me. Listener body is close to no-op. I would
expect either non-zero argument of `setTimeout` or, perhaps, even
`return` before `await`.

I have not had a look into commits. I hope, you added tests that race
between returned promise and `true` + `sendSesponse` is fair, so both
competitors may win depending on relative delay in listeners. Reading
Alexander's message I expected that this scenario is broken without any
relation to `undefined` as return value of `async` listener.

> Max is correct that the undefined -> null conversion is related
> to Chromium's JSON.stringify serialization of the messages. I'm
> currently working on finishing up the structured cloning implementation
> <https://issues.chromium.org/40321352>. Once that's done, `undefined`
> will be sent back to the message sender unmodified. It should also
> support Blobs, so I'm glad to hear that will be helpful.

It is great. My primary interest is propagation of exceptions from
content scripts to scripting `InjectionResult` `error` field. My
understanding is that structured clone is the key point.

<https://issues.chromium.org/40205757>/<https://crbug.com/1271527>
"Propagate errors from scripting.executeScript to InjectionResult"

> On Monday, November 10, 2025 at 12:54:40 PM UTC-5 Juraj M. wrote:
> My examples with the switch case is a simplified real world example
> - something I use in all of my extensions in multiple files.
> I have many handlers, and each is handling a specific set of messages.
> But if I made them all async, then the first handler would still
> consume all messages, because it would reply with "undefined" when
> it didn't handled the message.

My guess is that simple race allows to implement last resort timeout for
the case when actual listener hangs for some reason

browser.runtime.onMessage.addListener(
async () => new Promise((_resolve, reject) =>
setTimeout(() => reject(new Error("Timeout")))));

I suspect, it was just low hanging fruit to implement. In real life
cases you anyway wish to create an `AbortController` and to pass its
signal as a part of context to listener. It should allow to abandon
further async steps when early ones take too much time. So usefulness of
race between handlers is limited.

If you need conditional dispatching and multiple async listeners then
you may write a wrapper to ignore some value returned from async
function. I would consider some `Symbol("NotMine")` however instead of
`undefined` to notify dispatcher that specific listener is not going to
handle the message. Then promises resolved to `undefined` may be treated
as programming errors and reported as warnings. I do not think, this
kind of logic should be a part of extensions API.

Max Nikulin

unread,
Nov 11, 2025, 11:02:18 AMNov 11
to chromium-...@chromium.org
On 11/11/2025 05:06, Justin Lulejian wrote:
> @PhistucK: Yes, that was my mistake. Thank you for .catch()-ing that!

I decided, that `reject` was result of using LLM without careful
proof-reading.

Perhaps, I missed something, but the following in the original message
looks like confusion of return argument of `onMessage` handler and the
value returned from `sendMessage`:

> // content_script.js
> let response = await chrome.runtime.sendMessage('test');
> console.assert(response === true); // MDN
> console.assert(response === null); // Chromium

At least Firefox does not return true as promise value for async
handlers without explicit `return`.

Juraj M.

unread,
Nov 12, 2025, 2:37:30 AMNov 12
to Chromium Extensions, Max Nikulin
@Justin, yes the behavior is nicely aligned with Firefox / webextension-polyfill, and all of my extensions seems to work fine.
And I'm super happy to hear that Blob sending coming too! This will be a good time to finally ditch Chrome 109 support and finally clean-up my codebase. Thanks a lot!

But there is one more thing... and it's about that bug: https://issues.chromium.org/issues/458410308
I thought the new "reply with Promise" behavior will be activated only for people using the "browser" namespace - to avoid breaking existing extensions that uses async handlers.
I'm not affected by this but I can imagine a lot of extensions will be.

Justin Lulejian

unread,
Nov 12, 2025, 3:32:36 PMNov 12
to Chromium Extensions, Juraj M., Max Nikulin
@Juraj:

Thank you for confirming the behavior alignment, I appreciate it!

Regarding the browser namespace restriction you mentioned, supporting two distinct messaging behaviors unfortunately wasn't feasible at this time.

@Max:

IIUC your comment on returned promise + sendResponse/return true correctly,  you can see our end-to-end test cases here (each folder is a test extension). I think the testing you might be thinking of would be in the "on_message_return_.*_then_.*" folders. Our tests confirm they race correctly, but please let me know if I'm misunderstanding or if you see an edge case we've missed.

Max Nikulin

unread,
Nov 14, 2025, 11:09:52 AMNov 14
to chromium-...@chromium.org
On 13/11/2025 03:32, Justin Lulejian wrote:
> Regarding the browser namespace restriction you mentioned
> <https://issues.chromium.org/issues/40753031#comment31>,
> supporting two distinct
> messaging behaviors unfortunately wasn't feasible at this time.

Firefox have to maintain distinct API objects for better compatibility
with Chrome.

> IIUC your comment on returned promise + sendResponse/return true
> correctly,  you can see our end-to-end test cases here
> <https://crsrc.org/c/extensions/test/data/api_test/messaging/>
> (each folder is a
> test extension). I think the testing you might be thinking of would be
> in the "on_message_return_.*_then_.*" folders.

Thant you for the link. `on_message_return_true_then_promise` and its
counterparts cover cases I had in mind.

> On Wednesday, November 12, 2025 at 2:37:30 AM UTC-5 Juraj M. wrote:
> I thought the new "reply with Promise" behavior will be activated
> only for people using the "browser" namespace - to avoid breaking
> existing extensions that uses async handlers.
> I'm not affected by this but I can imagine a lot of extensions will be.

<https://github.com/darkreader/darkreader/commit/54f02c470d>
"Make document message listener sync function"

At first I did not realize why Dark reader was mentioned in the bug report.
Reply all
Reply to author
Forward
0 new messages