Published December 10th, 2024, Motiff Tech

Managing Asynchronous Tasks in Long-Lived Single Page Applications

Ethan Zhang
Ethan Zhang
Director of Engineering
Share This Post

Asynchronous operations, particularly within long-lived single-page applications like Motiff, pose a multifaceted and formidable challenge for developers.

Motiff, a web-based graphic editor, often sees users keep the page open for days or weeks without closing the browser tab. Each user interaction can trigger asynchronous requests, such as data retrieval and notification subscriptions during this time. Suppose these requests, initiated during an editor mode, are not meticulously managed when users switch back to the workspace within the same browser tab. In that case, they can lead to subtle instability issues. Over time, these minor issues can escalate, causing memory bloat or browser crashes.

Our team has dedicated considerable resources to understanding these challenges, examining how modern JavaScript addresses them, and crafting a robust programming strategy for handling asynchronous requests. We aim to bolster the editor’s stability over extended periods of use, and we have distilled our approach into three simple rules to solve the problem.

Challenges Arising from Lingering Asynchronous Tasks

Imagine this scenario: when planning a trip, you engage multiple agencies to book flights, hotels, train tickets, and car rentals, instructing them to secure the bookings even if it takes multiple attempts. Suppose the trip gets canceled, but you forget to notify the agencies. In that case, you might return to work only to find that one has successfully made a restaurant reservation because they were unaware of the cancellation. This situation highlights two significant issues with asynchronous operations.

  1. 1.How to effectively manage the cleanup of side effects when a critical condition changes?
  2. 2.How can tasks that should not proceed be prevented from continuing when there is a significant change in conditions?

Managing Side Effects

Managing side effects involves implementing cleanup code for any operation that generates such effects. In long-lived single-page applications, failing to clean up these side effects promptly can result in unstable program behavior and memory leaks.

For instance, after establishing a WebSocket connection, it is essential to implement cleanup logic to close the connection:

Another typical scenario of setting up a timer:

Subscribing to an observable example:

Alternatively, listening for DOM events:

Imagine needing to intercept global scroll events when switching to the editor. Suppose the global event listener is not removed when the user returns to the editor file list page. In that case, resources referenced by the function or captured by closures will not be released, potentially causing bugs if the editor’s onScroll callback runs while on the file list page. Thus, a mechanism is essential to manage the pattern of “enter the editor to perform actions / exit the editor to clean up actions.”

We could encapsulate these actions within a singleton service, offering a setup method for initialization and a destroy method for cleanup.

The problem is that the action and cleanup logic are not typically written together in the above code. As developers add more functionality to the action code, they often overlook the corresponding cleanup process. This oversight can lead to resource leaks and stability issues over time. Therefore, we must seek an alternative approach that tightly couples the action and cleanup, reducing the likelihood of such problems.

For example, using AbortSignal to register rollback callbacks via the signal’s abort event can tightly couple the action and cleanup code, ensuring that cleanup is defined immediately whenever an action is initiated.

Asynchronous Cancellation

Asynchronous cancellation pertains to halting an asynchronous task’s progression. Consider the following code:

The setupFont function does not run continuously; it releases the CPU during network requests, allowing other tasks to execute. Once the request is completed, the intent method passes the font data to the WASM memory.

This function is part of a more complex setupEditor process:

Notice the destroyEditor function. If a user exits the editor while the font is fetching, no one stops the loading function (setupFont), leading to unexpected behavior.

To address this, we introduce a signal. After exiting the editor, this signal is updated. After an await, the signal is checked for each asynchronous task to determine whether to proceed.

Here is a basic implementation of signal-based control:

This implementation ensures that if the editor exits during an asynchronous task, the task checks the editorAborted signal and stops if set, preventing further execution.

Using AbortController

AbortController is a native JavaScript class that adheres to a control and signal separation pattern. Each AbortController has a corresponding AbortSignal. When the abort method is called on the AbortController, the associated AbortSignal state is aborted. Using AbortController and AbortSignal, we can implement cancellation capabilities for asynchronous processes.

Rewrite the previous code using AbortController as follows:

This implementation uses AbortController to signal cancellation and checks the signal.aborted state to determine whether to proceed with each asynchronous step.

Two Ways to Perform Early Return

Deciding how to stop ongoing asynchronous tasks is crucial when dealing with the abort event. There are two primary approaches:

Using Return to Exit

In this way, the calling function must actively check the return value of the called function and decide whether to continue or exit.

This approach leads to scattered if (aborted) checks throughout the code and complicates the function’s return type, as it must return both the intended value and an aborted status.

Dealing with AbortableResult necessitates null checks for the result, adding further complexity.

Using return values to interrupt execution can be cumbersome compared to JavaScript’s exception mechanism.

Using Exceptions to Terminate Execution

The AbortSignal provides a throwIfAbort method, roughly equivalent to if (signal.aborted) throw new AbortError(signal.reason). This approach keeps function signatures unchanged and eliminates the need to pass the aborted state up the call stack, as exceptions propagate naturally.

However, ensuring that AbortError exceptions are not inadvertently caught is crucial. The following code illustrates the issue:

If we allow AbortError to be silently handled, every await would require a manual abort check afterward, resembling the earlier example in this section:

The Motiff project has over 20,000 await statements, so manually checking for aborts after each await is impractical. To avoid placing the burden of abort checks on the caller after every await, we should ensure that AbortError is not caught within catch blocks. Instead, each catch block should check for AbortError and rethrow it.

Alternatively, using try-catch:

By applying this pattern to approximately 500 catch blocks, we can ensure consistent handling of AbortError without requiring additional abort checks after every await. This change simplifies code maintenance and reduces the risk of overlooking abort conditions.

Summary: The Rules

To ensure that side effects are correctly cleaned up during page transitions, preventing callbacks from the previous page from executing in the context of the next page and causing errors, follow these three rules:

Rule 1: Abort Check

Methods accepting an AbortSignal parameter should not require the caller to check the abort status. Conversely, if a function does not take an AbortSignal, the caller must check for abort after an await.

Rule 2: Abort Propagation

Methods accepting an AbortSignal parameter should correctly throw an AbortError when the signal is aborted.

Rule 3: Cleanup Responsibility

The caller of methods accepting an AbortSignal should not be responsible for cleaning up the side effects produced by that method. Conversely, the caller must consider cleanup if a function does not take an AbortSignal.

Using AbortController and AbortSignal is essential for managing asynchronous tasks in long-lived single-page applications. The developer community has made significant efforts in this area, and by leveraging these tools while adhering to the three outlined rules, developers can effectively manage asynchronous tasks. Whether dealing with timers, network requests, or event listeners, these rules help ensure that side effects are promptly cleaned up during page transitions or when conditions change. They prevent memory leaks and reduce the risk of instability caused by unmanaged asynchronous operations, leading to more reliable performance and a better user experience for long-term usage.

Subscribe to Motiff Blog
I agree to opt-in to Motiff's mailing list.
By clicking "Subscribe" you agree to our TOS and Privacy Policy.