Skip to content

[browser] Backport #134124 to release/10.0 (Task-returning [JSExport] leaks a Promise per call) #136753

Open

Description

@robsonfelix

Follow-up to #132966 (closed and locked) and its fix #134124 (merged to main, also locked), so we could not ask there. Avalonia side: AvaloniaUI/Avalonia#22663.

We hit this in production on .NET 10.0.13 (browser-wasm, single-threaded, interpreter). Avalonia.Browser 12.x exports its timer callbacks (TimerHelper.JsExportOnTimeout / JsExportOnInterval) and its pointer, wheel and key handlers as [JSExport] methods returning Task, and they nearly always complete synchronously. Avalonia's dispatcher calls the timer ones roughly 370 times a second on an idle page, so every Avalonia browser app leaks continuously:

  • live JS heap after a forced full GC, idle page: 4.5 MB at load, 10.7 MB at 1 min, 37.5 MB at 5 min, 66.7 MB at 10 min (about 6 MB/min);
  • the browser's major GCs then freeze the main thread for 2–6 s (Chrome and Safari/WebKit), getting worse the longer the tab is open;
  • a heap snapshot after 60 s shows about 21,000 retained promise holders with their Promises and resolve/reject closures.

Minimal repro (no Avalonia):

// Microsoft.NET.Sdk.WebAssembly, net10.0, AllowUnsafeBlocks
public static partial class Exports
{
    [JSExport]
    public static Task Tick() => Task.CompletedTask;
}
const runtime = await dotnet.create();
await runtime.runMain();
const exports = await runtime.getAssemblyExports('Repro.dll');
for (let i = 0; i < 100000; i++) exports.Exports.Tick();

Live JS heap after a forced GC (Chrome, Release publish): 2.20 MB at start, 34.71 MB after 100,000 calls, 66.94 MB after 200,000.

Applying the two-statement change from #134124 to the shipped dotnet.runtime.js makes the same run flat: 2.20 → 2.57 → 2.58 MB. The change is that the eager Promise carries its holder's JSHandle, and end_marshal_task_to_js releases that handle instead of mono_wasm_get_js_handle(eagerPromise). With it, an idle Avalonia app stays at about 5.5 MB for 10 minutes. We currently patch the file at build time; a 10.0.x servicing fix would let every Blazor/Avalonia browser app drop such workarounds. Is a backport planned?

Activity

  1. self-assigned this
    on Nov 2, 2026
  2. added this to the 11.0.0 milestone on Nov 2, 2026
  3. added
    arch-wasmWebAssembly architecture
    os-browserBrowser variant of arch-wasm
    and removed
    untriagedNew issue has not been triaged by the area owner
    on Nov 2, 2026
  4. dotnet-policy-service commented on Nov 2, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
    See info in area-owners.md if you want to be subscribed.

  5. pavelsavara commented on Nov 2, 2026

    @pavelsavara
    Member

    We are going to consider Net11 backport #136846
    I think the memory leak doesn't meet the servicing bar for Net10, especially when it's probably there for a long time.

    Also it looks like Avalonia bug to call it some many times. Are they missing a debounce ?

  6. added
    needs-author-actionAn issue or pull request that requires more info or actions from the author.
    on Nov 4, 2026
  7. dotnet-policy-service commented on Nov 4, 2026

    @dotnet-policy-service
    Contributor

    This issue has been marked needs-author-action and may be missing some important information.

  8. robsonfelix commented on Nov 4, 2026

    @robsonfelix
    Author

    Thanks @pavelsavara. Two points on the servicing bar, and an answer on the Avalonia side.

    Why 10.0 servicing. .NET 10 is the LTS, so apps will stay on it until late 2028. A backport to 11.0 doesn't reach them. The cost is not just memory. The heap grows without bound, and the browser's major GCs then freeze the main thread for 2–6 s in Chrome and Safari, worse the longer the tab stays open. Any long-lived page hits this: dashboards, kiosks, back-office apps. The fix is the two-statement change from #134124, already on main and in the 11.0 backport, and we have run it patched into the shipped dotnet.runtime.js without problems.

    On the call rate. Agreed that Avalonia shouldn't need ~370 calls a second on an idle page. That's AvaloniaUI/Avalonia#22663, and we've asked them to return void from those exports. But the leak is per call, not per rate. Any Task-returning [JSExport] that completes synchronously leaks one Promise, so a lower rate only slows it down. Pointer-move and wheel handlers legitimately fire at 60–120 Hz while the user is active.

    If 10.0 servicing is still a no, could you confirm that changing such exports to return void is the supported workaround on 10.0? Then libraries can switch to it without waiting for a runtime fix.

  9. added
    needs-further-triageIssue has been initially triaged, but needs deeper consideration or reconsideration
    and removed
    needs-author-actionAn issue or pull request that requires more info or actions from the author.
    on Nov 4, 2026
  10. modified the milestones: 11.0.0, 10.0.x on Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions