Skip to content

resource: destroying a resource calls AbortController.abort() without a reason, creating an AbortError with a stack trace each time #71422

Closed

Description

@demike

Which @angular/* package(s) are the source of the bug?

core

Is this a regression?

No

Description

Destroying a resource() calls AbortController.abort() without a reason. Per the DOM Standard that creates a new "AbortError" DOMException for every destroyed resource, and in Chromium creating it captures a JavaScript stack trace whose cost grows with the depth of the call stack. Resources are destroyed inside the view destroy recursion, where the stack is deep, so this adds up when a page with many resources is torn down.

Where it happens (main at e0260db):

So every resource that has loaded once calls abort() when it is destroyed.

What the spec says:

  • abort(reason): "The abort(reason) method steps are to signal abort on this with reason if it is given."
  • signal abort, step 2: "Set signal's abort reason to reason if it is given; otherwise to a new "AbortError" DOMException."

Measurements (Chromium 153 via Playwright 1.63, 4× CPU throttling, default Error.stackTraceLimit of 10), cost per call:

Call stack depth abort() abort(reason)
5 14 µs 0.8 µs
100 50 µs 1.8 µs
300 150 µs 1.0 µs

In our application, navigating away from a page destroys 88–95 resources. Their abort() calls take 24–35 ms per navigation (4× throttling, about 300 µs per call inside the destroy recursion). With a shared reason (see the workaround below) they take 5–10 ms; the rest is the abort listeners themselves (unsubscribing).

Possible fix: pass a reason, for example one DOMException('...', 'AbortError') created once at module level. ResourceImpl itself only checks abortSignal.aborted, and loaders that forward the signal (e.g. to fetch) still see an AbortError as the reason, so the observable behaviour stays the same apart from the shared instance.

Workaround we use until then, in the application bootstrap:

const sharedAbortReason = new DOMException('signal is aborted without reason', 'AbortError');
const nativeAbort = AbortController.prototype.abort;
AbortController.prototype.abort = function (reason?: unknown) {
  nativeAbort.call(this, reason === undefined ? sharedAbortReason : reason);
};

Please provide a link to a minimal reproduction of the bug

No StackBlitz. The cost can be reproduced in any Chromium console, independent of Angular:

const n = 500;
const atDepth = (depth, fn) => (depth === 0 ? fn() : atDepth(depth - 1, fn));
const measure = (depth, op) =>
  atDepth(depth, () => {
    const controllers = Array.from({ length: n }, () => new AbortController());
    const start = performance.now();
    for (const c of controllers) op(c);
    return `${(((performance.now() - start) / n) * 1000).toFixed(1)} µs`;
  });
for (const depth of [5, 100, 300]) {
  console.log(depth, 'abort():', measure(depth, (c) => c.abort()), 'abort(reason):', measure(depth, (c) => c.abort('destroyed')));
}

On the Angular side, it shows up whenever a view tree with many resource()/rxResource() instances is destroyed, e.g. on a route change.

Please provide the exception or error you saw

No error. Per destroyed resource, an AbortError DOMException including a stack trace is created.

Please provide the environment you discovered this bug in (run ng version)

Angular: 22.1.8 (the code above is unchanged on main e0260db)
Node: 24.18.0
Browser: Chromium 153 (Playwright 1.63)

Anything else?

No response

Activity

  1. added this to the needsTriage milestone on Oct 19, 2026
  2. demike commented on Oct 19, 2026

    @demike
    ContributorAuthor

    Some more numbers, to show how this scales. Measured in a blank page (Chromium 153), with and without CPU throttling; the table in the description is the 4× column (throttled to emulate a low-end device, which is where we noticed it).

    Cost per call by call stack depth:

    Call stack depth abort() 1× abort(reason) 1× abort() 4× abort(reason) 4×
    5 2.9 µs 0.2 µs 12 µs 0.6 µs
    100 14 µs 0.1 µs 78 µs 0.6 µs
    300 28 µs 0.2 µs 141 µs 1.0 µs
    1000 28 µs 0.1 µs 145 µs 0.6 µs

    Total time by number of destroyed resources (one abort listener each, like rxResource; depth 300; mean of 20 runs):

    Resources abort() 1× abort(reason) 1× abort() 4× abort(reason) 4×
    10 0.26 ms 0.01 ms 1.6 ms 0.03 ms
    50 1.8 ms 0.03 ms 8.4 ms 0.12 ms
    100 2.9 ms 0.02 ms 15.8 ms 0.23 ms
    250 7.5 ms 0.1 ms 39 ms 0.5 ms
    500 14.7 ms 0.17 ms 80 ms 1.1 ms
    1000 30 ms 0.4 ms 165 ms 2.1 ms

    So the cost grows linearly with the number of resources, about 30 µs per resource on a desktop CPU and about 160 µs on a low-end device, while abort(reason) stays negligible. In our application ~90 resources are destroyed per navigation (24–35 ms with 4× throttling).

    Snippet for the console (use the Performance panel's CPU throttling for the 4× numbers):

    const atDepth = (d, fn) => (d === 0 ? fn() : atDepth(d - 1, fn));
    // mean ms to abort `count` controllers (one abort listener each, like rxResource) at call stack depth `depth`
    const teardown = (count, depth, reason, runs = 20) => {
      let total = 0;
      for (let run = 0; run < runs; run++) {
        const controllers = Array.from({ length: count }, () => {
          const c = new AbortController();
          c.signal.addEventListener('abort', () => {});
          return c;
        });
        total += atDepth(depth, () => {
          const start = performance.now();
          for (const c of controllers) reason === undefined ? c.abort() : c.abort(reason);
          return performance.now() - start;
        });
      }
      return `${(total / runs).toFixed(2)} ms`;
    };
    for (const count of [10, 50, 100, 250, 500, 1000]) {
      console.log(count, 'abort():', teardown(count, 300), 'abort(reason):', teardown(count, 300, 'destroyed'));
    }
  3. added a commit that references this issue on Oct 28, 2026
    09fe7ec
  4. added a commit that references this issue on Nov 10, 2026
    c489b85
  5. added a commit that references this issue on Nov 10, 2026
    833c1f2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimecross-cutting: resourceIssues related to the newly introduced resource / httpResource

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions