Core Library
MSAL.js (@azure/msal-browser)
Core Library Version
5.10.0
Wrapper Library
Not Applicable
Wrapper Library Version
None
Public or Confidential Client?
Description
new PublicClientApplication(...) opens a BroadcastChannel inside EventHandler and never closes it. PublicClientApplication exposes no public method to release it, so the underlying MessagePort keeps Node's event loop alive forever.
In a browser this is harmless — the channel goes away when the tab closes — but in any Node-based context (jest / vitest / Playwright globalSetup / one-off scripts) the process never exits naturally. We hit this on Linux CI as a jest run that hung silently for 60+ minutes; jest's --detectOpenHandles traced it directly to new BroadcastChannel(...) in EventHandler's constructor.
Error Message
No error — the process simply never exits. process._getActiveHandles() reports a leaked MessagePort after construction:
done
active handles: [ 'MessagePort' ]
<hangs indefinitely>
MSAL Logs
Not applicable — the bug reproduces on construction, before any operation that produces MSAL logs.
Network Trace (Preferrably Fiddler)
Not applicable — no network calls involved.
MSAL Configuration
{
auth: {
clientId: "00000000-0000-0000-0000-000000000000",
authority: "https://login.microsoftonline.com/common"
}
}
Relevant Code Snippets
// repro.cjs
const { PublicClientApplication } = require('@azure/msal-browser')
new PublicClientApplication({
auth: {
clientId: '00000000-0000-0000-0000-000000000000',
authority: 'https://login.microsoftonline.com/common',
},
})
console.log('done')
setImmediate(() => {
console.log('active handles:', process._getActiveHandles().map(h => h.constructor.name))
})
Reproduction Steps
package.json:
{ "name": "msal-handle-leak-repro", "type": "commonjs", "private": true }
npm install @azure/msal-browser@5.10.0
node repro.cjs
Bisection:
@azure/msal-browser |
Behaviour |
| 3.30.0 |
exits cleanly, active handles: [] |
| 4.1.0 |
exits cleanly, active handles: [] |
| 4.2.0 |
hangs, MessagePort leaked ← regression introduced here |
| 4.10.0 / 4.27.0 |
hangs, MessagePort leaked |
| 5.10.0 (latest) |
hangs, MessagePort leaked |
Tested on Node 20.20.2 and 22.22.2 — same outcome.
Expected Behavior
Process prints done, reports active handles: [], and exits with code 0.
Root Cause
Between v4.1.0 and v4.2.0, src/event/EventHandler.ts gained an unconditional BroadcastChannel (v5.10.0 made it conditional on typeof BroadcastChannel !== 'undefined', but Node ≥18 has it as a global, so the channel is still always created):
// EventHandler.ts (v5.10.0)
constructor(logger?: Logger) {
this.eventCallbacks = new Map();
this.logger = logger || new Logger({});
if (typeof BroadcastChannel !== "undefined") {
this.broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
}
this.invokeCrossTabCallbacks = this.invokeCrossTabCallbacks.bind(this);
}
Across the entire msal-browser source, broadcastChannel appears at exactly one creation and three usages (postMessage, addEventListener, removeEventListener) — .close() is never called anywhere.
The IPublicClientApplication interface exposes no dispose / destroy / close / teardown method. clearCache() only clears storage. There is no documented or public way for a consumer to release the channel.
Related workaround in the repo
The library's own jest setup at shared-configs/jest-config/setupGlobals.cjs wraps the global BroadcastChannel with a TrackedBroadcastChannel and exposes global.cleanupMsalBroadcastChannels / global.forceCleanupMsalBroadcastChannels, which iterate every tracked channel and call .close().
Suggested Fix
Either:
- Add a public
close() / dispose() to PublicClientApplication that delegates to EventHandler (and any other component holding native handles), closing the broadcast channel.
- Lazily construct the
BroadcastChannel when the first cross-tab callback is registered and close it when the last one is removed. Currently it's eagerly created even when no consumer ever uses cross-tab events.
Option 2 has the nice property of making the leak disappear for the common case (no cross-tab listeners), without requiring a behaviour change for any consumer.
Identity Provider
Entra ID (formerly Azure AD) / MSA
Browsers Affected (Select all that apply)
Regression
@azure/msal-browser 4.1.0
Core Library
MSAL.js (@azure/msal-browser)
Core Library Version
5.10.0
Wrapper Library
Not Applicable
Wrapper Library Version
None
Public or Confidential Client?
Description
new PublicClientApplication(...)opens aBroadcastChannelinsideEventHandlerand never closes it.PublicClientApplicationexposes no public method to release it, so the underlyingMessagePortkeeps Node's event loop alive forever.In a browser this is harmless — the channel goes away when the tab closes — but in any Node-based context (jest / vitest / Playwright globalSetup / one-off scripts) the process never exits naturally. We hit this on Linux CI as a jest run that hung silently for 60+ minutes; jest's
--detectOpenHandlestraced it directly tonew BroadcastChannel(...)inEventHandler's constructor.Error Message
No error — the process simply never exits.
process._getActiveHandles()reports a leakedMessagePortafter construction:MSAL Logs
Not applicable — the bug reproduces on construction, before any operation that produces MSAL logs.
Network Trace (Preferrably Fiddler)
Not applicable — no network calls involved.
MSAL Configuration
Relevant Code Snippets
Reproduction Steps
package.json:{ "name": "msal-handle-leak-repro", "type": "commonjs", "private": true }Bisection:
@azure/msal-browseractive handles: []active handles: []MessagePortleaked ← regression introduced hereMessagePortleakedMessagePortleakedTested on Node 20.20.2 and 22.22.2 — same outcome.
Expected Behavior
Process prints
done, reportsactive handles: [], and exits with code 0.Root Cause
Between v4.1.0 and v4.2.0,
src/event/EventHandler.tsgained an unconditionalBroadcastChannel(v5.10.0 made it conditional ontypeof BroadcastChannel !== 'undefined', but Node ≥18 has it as a global, so the channel is still always created):Across the entire msal-browser source,
broadcastChannelappears at exactly one creation and three usages (postMessage,addEventListener,removeEventListener) —.close()is never called anywhere.The
IPublicClientApplicationinterface exposes nodispose/destroy/close/teardownmethod.clearCache()only clears storage. There is no documented or public way for a consumer to release the channel.Related workaround in the repo
The library's own jest setup at
shared-configs/jest-config/setupGlobals.cjswraps the globalBroadcastChannelwith aTrackedBroadcastChanneland exposesglobal.cleanupMsalBroadcastChannels/global.forceCleanupMsalBroadcastChannels, which iterate every tracked channel and call.close().Suggested Fix
Either:
close()/dispose()toPublicClientApplicationthat delegates toEventHandler(and any other component holding native handles), closing the broadcast channel.BroadcastChannelwhen the first cross-tab callback is registered and close it when the last one is removed. Currently it's eagerly created even when no consumer ever uses cross-tab events.Option 2 has the nice property of making the leak disappear for the common case (no cross-tab listeners), without requiring a behaviour change for any consumer.
Identity Provider
Entra ID (formerly Azure AD) / MSA
Browsers Affected (Select all that apply)
Regression
@azure/msal-browser 4.1.0