> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Reviewing the Async Call Nobody Could Cancel
- URL: https://debugly.dev/reviewing-async-without-cancellation/
- Published: 2026-10-09T17:01:00.000Z
- Updated: 2026-10-23T09:12:44.000Z
- Description: The request had no timeout and no cancellation, so the abandoned navigation left the fetch running, and the response wrote to a component that no longer…
- Author: Rohit Bhadani
- Tags: Code Review, JavaScript, Reliability

I started at the missing cancellation, because that is where the symptom landed, and walked it backwards link by link until the crashed stopped looking innocent.

Here is the shape of the uncancelable async call, and the one property that makes it a leak and a crash: the request has no lifetime tied to the thing that started it, so it outlives the component, the navigation, and sometimes the user's interest, and the completion handler runs against a world that no longer exists.

This was React with a plain fetch, and the mechanics are the same for any async call started from a component or a request handler.

## What the review saw

The review saw the fetch, and the fetch was the await, and the await was the idiomatic, and the idiomatic was the pass, because the code was the correct JavaScript, and the correct was the review's criterion, and the criterion was met, and the met was the miss, because the lifetime was the absent, and the absent is not a line to read.

The review did not ask what happens when the component unmounts mid-request, and the ask is the question, and the question is the cancellation, and the cancellation is the AbortController, and the controller is the fix, and the fix is the absent, and the absent is the incident, and the incident is the reason the review should ask what cancels this.

The absent is the review's blind spot, because the happy path is the mounted component and the arriving response, and the happy path is the pass, and the pass is the navigation's absence, and the absence is the real usage, and the real usage is the incident, and the incident is the question nobody asked.

## Why the missing timeout made it worse

The fetch had the no timeout, and the no timeout was the forever, and the forever was the hung request, and the hung was the connection's hold, and the hold was the resource, and the resource was the leak, and the leak was the accumulation, and the accumulation was the browser's limit, and the limit was the queued requests, and the queued was the slow page, and the slow was the incident.

The no timeout is also the no failure, and the no failure is the silent, and the silent is the user's wait, and the wait is the spinner, and the spinner is the forever, and the forever is the complaint, and the complaint is the incident, and the incident is the reason the timeout is the non-negotiable.

This is the same lifetime problem as [the useEffect that never cleaned up its subscription](https://debugly.dev/reviewing-useeffect-missing-cleanup/), and the common root is the property, not the platform.

## The questions that catch it

**What cancels this when the component unmounts?** The answer is the controller, and the controller is the abort, and the abort is the fix, and the fix is the cleanup, and the cleanup is the return, and the return is the discipline, and the question is the review's first.

**What is the timeout?** The answer should be the number, and the number is the bound, and the bound is the fix, and the fix is the controller's timer, and the timer is the discipline, and the question is the review's second, because the no timeout is the forever.

**What happens if the response arrives after the unmount?** The answer is the guard or the abort, and the two are the fix, and the fix is the state's safety, and the safety is the no crash, and the crash is the incident, and the question is the review's third.

**Is the error handled, including the abort?** The abort is the rejection, and the rejection is the handled, and the handled is the not the uncaught, and the uncaught is the noise, and the noise is the fix, and the question is the review's fourth, because the abort error should be swallowed deliberately.

## The fix

**Passed an AbortController signal to the fetch.** The signal is the cancellation, and the cancellation is the unmount's abort, and the abort is the request's end, and the end is the fix, and the fix is the one option, and the option is the discipline.

**Aborted in the cleanup function.** The effect returned the abort, and the returned was the unmount's trigger, and the trigger was the fix, and the fix was the return, and the return was the one line, and the line was the prevention.

**Added a timeout that aborts.** The timer aborts the controller after the budget, and the budget is the bound, and the bound is the fix, and the fix is the setTimeout, and the timeout is the discipline, because the fetch has no built-in timeout.

**Checked the mounted state before the state write.** The guard is the ref, and the ref is the current, and the current is the write's condition, and the condition is the fix, and the fix is the belt to the abort's braces, and the two are the defence in depth.

**Handled the AbortError explicitly.** The error name is the check, and the check is the ignore, and the ignore is the deliberate, and the deliberate is the fix, and the fix is the branch, and the branch is the discipline, because the unhandled abort is the console's noise and the noise hides the real error.

## The rule I keep

An async call started from a component must be cancelable, or it outlives the component and its completion handler runs against an unmounted tree. Pass an AbortController signal, abort in the cleanup, add an explicit timeout that aborts, guard the state write, and handle the AbortError deliberately.

The user navigated away and the fetch kept running, and when it finally resolved it wrote to a component that had unmounted minutes earlier. I now check for that property first, because it is where the failure actually lives.

---

Related reading: [Reviewing the Timeout That Was Longer Than the Caller's](https://debugly.dev/reviewing-timeout-longer-than-caller/) explores a connected angle.

Related reading: [Reviewing the Retry That Had No Idempotency Key](https://debugly.dev/reviewing-retry-without-idempotency-key/) explores a connected angle.

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)