Async cancellation with AbortController

Modern web applications often perform asynchronous operations such as network requests, user-driven workflows, or long-running computations. In many cases, these operations may need to be cancelled before completion to improve performance, reduce unnecessary server load, and provide a smoother, more responsive user experience. The AbortController and AbortSignal APIs provide a standard way to handle such cancellation.

The AbortController pattern

The AbortController API is the standard way to signal cancellation in JavaScript. It creates an associated AbortSignal, which serves as a communication channel between the controller and any asynchronous operation. When abort() is called on the controller, the signal's aborted property becomes true. This lets the function detect cancellation and respond by stopping execution, performing cleanup, or throwing an AbortError.

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
const controller = new AbortController();
const signal = controller.signal;

// Pass `signal` to an async operation:
someAsyncFunction({ signal });

// cancel the operation
controller.abort();

Using AbortController with the ArcGIS Maps SDK for JavaScript

Many methods in ArcGIS Maps SDK for JavaScript (@arcgis/core) support this standard by accepting an AbortSignal. This enables robust, responsive cancellation of tasks that might otherwise be left running unnecessarily. Cancellation is particularly valuable in high-frequency or interruptible workflows, such as:

  • Querying features as the mouse moves over a map
  • Animating view transitions as the user pans or zooms
  • Adjusting a slice plane in a 3D scene

An AbortSignal can be passed as part of the options object to methods like FeatureLayer.queryFeatures, MapView.goTo, reactiveUtils.whenOnce, among other methods. When the signal is aborted, the operation terminates early, helping conserve resources and ensuring your application remains responsive.

Always check the official core API documentation for the specific method you're using to confirm support for AbortSignal.

Example: Using AbortSignal with reactiveUtils.whenOnce()

reactiveUtils.whenOnce() returns a promise that resolves the first time a specified reactive condition becomes true. It supports cancellation via an AbortSignal, which allows you to stop waiting if the condition is no longer relevant. If aborted, the promise rejects with an AbortError.

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Use an AbortSignal to cancel waiting for the view's animation
const abortController = new AbortController();

// Wait until view.animation exists (i.e., becomes truthy) or cancel if aborted
reactiveUtils.whenOnce(
  () => view?.animation,
  { signal: abortController.signal }
)
.then((animation) => {
  // This runs only if the animation becomes truthy before aborting
  console.log(`View animation state is ${animation.state}`);
})
.catch((error) => {
  if (error.name === "AbortError") {
    // This runs if the wait was cancelled before the condition became true
    console.log("Animation observation cancelled");
  } else {
    throw error;
  }
});

// Stop waiting for the condition to be truthy
const cancelObservation = () => {
  abortController.abort();
};

Cancelling the whenOnce() promise is useful in scenarios where the condition may no longer matter, for example, if the user navigates away or another condition becomes more relevant. It helps avoid executing stale or unnecessary logic that could confuse users or waste resources.

Error handling and AbortError

When an operation is cancelled, most APIs (including the ArcGIS SDK) reject the returned promise with an AbortError. It is good practice to check for AbortError in your error handling logic. This specific error is thrown when a signal is aborted and indicates intentional cancellation, not a failure of the operation. Catching it explicitly allows your app to differentiate between user-driven cancellation and unexpected failures—preventing misleading error messages or unnecessary retries.

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
try {
  const results = await layerView.queryFeatures(
    {
      where: "someField = 'someValue'",
      returnGeometry: true
    },
    { signal: controller.signal }
  );
  console.log("Query completed:", results.features);
} catch (error) {
  if (error.name === "AbortError") {
    console.log("Query operation was cancelled.");
    return;
  }
  throw error; // Handle other errors as needed.
}

Managing repeated operations and avoiding race conditions

When user input can trigger operations repeatedly, such as clicks or mouse moves, or slider changes, it is important to cancel any ongoing operation before starting a new one. Failing to do so can lead to

  • Race conditions: where older operations overwrite newer results
  • Memory leaks: if references to old AbortControllers linger

In this case, use a pattern that tracks the current operation’s AbortController and aborts it before launching a new one. Always check if the operation’s signal is still current before processing results, and clean up references properly to avoid leaks.

The queryFeatures() method queries on feature layers or their layer views and supports cancellation via an AbortSignal passed as the second options parameter. This lets you cancel ongoing queries when input changes, such as from a calcite slider. Aborting is important because rapid input changes can trigger multiple queries; without cancellation, outdated queries waste resources and may overwrite newer results. Cancelling previous queries avoids outdated results and ensures only the latest input is processed.

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
let currentController;
const slider = document.querySelector("calcite-slider");
const layerView = await viewElement.whenLayerView(layer);

slider.addEventListener("calciteSliderInput", async () => {
  // Cancel any ongoing query
  currentController?.abort();

  // Create a new controller for the current query
  currentController = new AbortController();
  const { signal } = currentController;

  const sliderValue = slider.value;

  try {
    // Query features with a filter based on the slider's value
    const results = await layerView.queryFeatures(
      {
        where: `someField = ${sliderValue}`,
        returnGeometry: true
      },
      { signal }
    );

    if (signal.aborted) {
      // Query was cancelled, ignore results
      return;
    }

    // Process the results — for example, update the map or
    // a chart with the new features
    console.log("Query results:", results.features);
  } catch (error) {
    if (error.name === "AbortError") {
      console.log("Query cancelled due to slider input change.");
    } else {
      throw error;
    }
  } finally {
    // Clear the controller if it's still the current one
    if (currentController?.signal === signal) {
      currentController = null;
    }
  }
});

This pattern ensures only the most relevant operation completes, avoids memory leaks, and helps maintain UI consistency.

Looping and repeated operations

For repeated or continuous workflows (e.g., placing multiple objects until cancelled), use a loop with cancellation support. This pattern lets you start a cancellable async operation multiple times, waiting for each placement to complete before starting the next—while allowing the user to cancel the entire sequence at any time. The key is to check the AbortSignal regularly and handle cancellation gracefully. The loop continues calling the async operation until the signal is aborted or you decide to stop.

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function startLoopedPlacement(): Promise<void> {
  currentController?.abort();
  currentController = new AbortController();
  const signal = currentController.signal;

  try {
    while (true) {
      // Continue placing until cancelled or explicitly stopped
      await analysisView.place({ signal });

      // Check if the user cancelled during the placement
      if (signal.aborted) {
        console.log("Placement operation was cancelled.");
        break; // Exit the loop on cancellation
      }

      // Add logic to exit the loop if needed.
    }
  } catch (error) {
    // ...handle errors...
  } finally {
    if (currentController?.signal === signal) {
      currentController = null;
    }
  }
}

Helpful utilities in @arcgis/core

The promiseUtils module provides a collection of helpful utilities designed to simplify working with promises, especially when handling asynchronous operations that support cancellation. These helpers make it easier to manage common scenarios such as detecting cancellation errors, debouncing function calls to avoid redundant executions, and controlling the lifecycle of promise-based workflows in a clean and efficient way.

The promiseUtils.isAbortError

The isAbortError() function helps you quickly check if an error is caused by an operation being cancelled with an AbortError. This makes it easy to handle cancellations separately from real errors, allowing your app to respond appropriately without confusing the two.

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
import { isAbortError } from "@arcgis/core/core/promiseUtils.js";

try {
  await someAsyncOperation({ signal: controller.signal });
} catch (error) {
  if (isAbortError(error)) {
    console.log("Operation was cancelled.");
  } else {
    throw error;
  }
}

The promiseUtils.debounce

The promiseUtils.debounce() ensures an async function is not invoked more than once at a time. If the function is called again before a previous call has completed, the previous call is automatically cancelled using AbortController. This is especially useful in highly interactive applications, such as those that:

  • Query statistics on mouse move
  • Respond to drag gestures
  • Recalculate results on rapid input changes such as dragging slider thumbs

By cancelling prior calls, debounce() reduces unnecessary work and improves performance and responsiveness. To see debounce() in action, explore samples that use this method.

Custom Utility Functions

While not built into @arcgis/core, the following utilities can simplify error-handling patterns:

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import * as promiseUtils from "@arcgis/core/core/promiseUtils.js";

class AbortError extends Error {
  constructor(message = "The operation was aborted") {
    super(message);
    this.name = "AbortError";
  }
}

// Throws immediately if the signal is already aborted.
function throwAbortErrorIfAborted(signal: AbortSignal): void {
  if (signal.aborted) {
    throw new AbortError();
  }
}

// Rethrows only if the error is an AbortError.
function throwIfAbortError(error: unknown): void {
  if (promiseUtils.isAbortError(error)) {
    throw error;
  }
}

// Rethrows only if the error is NOT an AbortError.
function throwIfNotAbortError(error: unknown): void {
  if (!promiseUtils.isAbortError(error)) {
    throw error;
  }
}

// Usage example
try {
  await someAsyncOperation({ signal: controller.signal });
} catch (error) {
  // Replaces previous logic: catch abort errors, rethrow others.
  throwIfNotAbortError(error);
}

Combining multiple AbortSignals

Sometimes, an operation may depend on multiple sources of cancellation, such as user actions or timeout timers. In these cases, you want the operation to cancel as soon as any of the related signals is aborted. This pattern lets your async operation respond to multiple cancellation triggers, such as user actions or timeouts. To cancel an operation when any of several signals is aborted, use AbortSignal.any(). This method returns a new AbortSignal that becomes aborted as soon as any of the input signals is aborted.

This is useful for coordinating cancellation logic across different parts of your app without manually listening to multiple signals.

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const userAbortController = new AbortController();
const timeoutAbortController = new AbortController();

// Automatically abort after 5 seconds
setTimeout(() => timeoutAbortController.abort(), 5000);

// Create a combined signal that aborts if either user cancels or timeout triggers
const combinedSignal = AbortSignal.any([
  userAbortController.signal,
  timeoutAbortController.signal,
]);

try {
  await someAsyncOperation({ signal: combinedSignal });
  console.log("Operation completed successfully.");
} catch (error) {
  if (error.name === "AbortError") {
    console.log("Operation cancelled by user or timed out.");
  } else {
    throw error;
  }
}

// Example: Cancel operation manually (e.g., user clicks cancel button)
function cancelOperation() {
  userAbortController.abort();
}

This pattern allows your async operation to respond to multiple cancellation triggers gracefully, making your app more responsive and robust.

Best practices and common pitfalls

1. Aborting does not instantly stop operations

Calling abort() on an AbortController does not immediately stop an ongoing operation. It simply signals cancellation by setting the associated AbortSignal.aborted flag to true. The actual termination depends on the operation’s implementation. Some may ignore the signal or take time to respond.

To ensure cancellation works as expected, use APIs that support AbortSignal, or handle the signal manually in your own async code:

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
async function longOperation({ signal }) {
  for (let i = 0; i < 10000; i++) {
    if (signal.aborted) {
      throw new AbortError();
    }
    await doWork(i); // Simulated long-running task
  }
}

2. Avoid side effects if the signal was aborted

Even if an async operation completes, cancellation may have occurred during the await. Always check signal.aborted before performing side effects like updating UI, processing results, or triggering follow-up tasks. This avoids applying stale or irrelevant data after the user has moved on.

Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
await doSomethingAsync({ signal });

if (signal.aborted) {
  // Skip side effects — the result is no longer valid
  return;
}

// Only runs if the operation wasn’t cancelled
performSideEffect();

Using AbortController and AbortSignal effectively enables your ArcGIS JavaScript applications to handle cancellation robustly. Implementing these patterns reduces wasted resources, avoids race conditions, and improves user experience. Explore these samples to see AbortController in action.

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.