Start HereOrdered learning track

AbortController, Timeout, and Cancellation

Learn React Client-Server Communication - Part 012

AbortController, AbortSignal, timeout, deadline, cancellation composition, React cleanup, stale response prevention, dan batas nyata pembatalan request terhadap side effect server.

12 min read2382 words
PrevNext
Lesson 1272 lesson track01–13 Start Here
#react#client-server#fetch-api#abortcontroller+5 more

Part 012 — AbortController, Timeout, and Cancellation

Cancellation sering diajarkan sebagai cleanup kecil:

useEffect(() => {
  const controller = new AbortController()

  fetch('/api/me', { signal: controller.signal })

  return () => controller.abort()
}, [])

Itu benar, tetapi belum cukup.

Dalam production React app, cancellation adalah bagian dari correctness model.

Ia menentukan:

  • response lama boleh menulis state atau tidak,
  • request yang sudah tidak relevan harus menghabiskan bandwidth atau tidak,
  • timeout user experience diputuskan di mana,
  • query baru membatalkan query lama atau berjalan paralel,
  • navigation membatalkan loader atau tidak,
  • mutation boleh dibatalkan atau hanya UI-nya yang berhenti menunggu,
  • retry masih masuk akal atau sudah melewati deadline,
  • server side effect sudah terjadi atau belum.

Mental model utama:

Cancellation is not just stopping work. Cancellation is declaring that a result no longer has authority.


1. Why Cancellation Exists

Tanpa cancellation, React app mudah mengalami bug berikut:

1. Stale response overwrite

User mencari alice, lalu cepat mengetik alex.

2. Wasted work

User pindah halaman, tetapi request halaman lama tetap berjalan.

3. Infinite waiting

Server/gateway tidak merespons, UI terus loading.

4. Incorrect retry

Client retry setelah user sudah membatalkan flow.

5. Mutation ambiguity

User cancel saat request pembayaran sedang berjalan. Apakah pembayaran batal? Belum tentu.

Cancellation menyelesaikan sebagian dari masalah ini. Tapi tidak semuanya.


2. AbortController and AbortSignal Anatomy

Core API:

const controller = new AbortController()
const signal = controller.signal

controller.abort()

Fetch menerima signal:

await fetch('/api/orders', { signal })

Saat signal aborted:

  • fetch dapat reject,
  • body consumption dapat abort,
  • stream dapat berhenti,
  • code bisa memeriksa signal.aborted,
  • reason dapat dibaca melalui signal.reason pada browser modern.

Basic guard:

try {
  await fetch('/api/me', { signal })
} catch (error) {
  if (signal.aborted) {
    return
  }

  throw error
}

Signal adalah read-only side. Controller adalah write side.


3. Signal Is One-Shot

AbortSignal hanya bisa bergerak dari not aborted ke aborted.

Ia tidak bisa di-reset.

Bad:

const controller = new AbortController()

async function search(q: string) {
  controller.abort()

  // Bug: signal sudah aborted. Request baru langsung gagal.
  return fetch(`/api/search?q=${q}`, {
    signal: controller.signal,
  })
}

Better:

let currentController: AbortController | null = null

async function search(q: string) {
  currentController?.abort('superseded')

  const controller = new AbortController()
  currentController = controller

  return fetch(`/api/search?q=${encodeURIComponent(q)}`, {
    signal: controller.signal,
  })
}

Rule:

One operation attempt owns one controller.

Jika operation diulang, buat controller baru.


4. Cancellation Taxonomy

Tidak semua cancellation sama.

Cancellation sourceContohMeaning
unmountcomponent hilangresult no longer has UI owner
dependency changeuserId berubahold result no longer matches input
navigationroute pindahpage-level data no longer relevant
timeoutdeadline habisuser/app budget exceeded
supersededsearch/filter baruold operation replaced
manual user canceluser klik canceluser intent withdrawn
global shutdownlogout/app resetall active work invalid
resource budgettoo many inflight requestsprotect app/browser/server

Kenapa taxonomy penting?

Karena UI dan telemetry berbeda.

SourceUser message?Log as error?Retry?
unmountnonono
dependency changenonono
superseded searchnonono
timeoutmaybeyes/metricmaybe
manual cancelmaybe “Cancelled”nono
logoutnonono
resource budgetmaybeyescontrolled

Jika semua cancellation disebut “error”, observability akan penuh noise.


5. Timeout Is a Policy, Not a Magic Number

Timeout bukan sekadar:

setTimeout(() => controller.abort(), 10_000)

Timeout adalah budget.

Budget harus menjawab:

  • berapa lama user rela menunggu?
  • operation ini interactive atau background?
  • apakah server masih memproses setelah timeout?
  • apakah retry masih boleh?
  • apakah timeout dihitung per attempt atau total deadline?
  • apakah fallback tersedia?

Contoh budget:

OperationSuggested budget
autocomplete300–1000 ms
route critical data3–10 s
save draft5–15 s
export job creation10–30 s
file uploaddynamic/progress-based
background prefetchlow priority, cancellable

Angka di atas bukan hukum. Gunakan telemetry dan UX context.


6. AbortSignal.timeout()

Browser modern menyediakan:

const signal = AbortSignal.timeout(5_000)

await fetch('/api/me', { signal })

Ini membuat signal yang otomatis abort setelah durasi tertentu.

Keuntungan:

  • sederhana,
  • tidak perlu manual setTimeout,
  • reason timeout lebih eksplisit di platform modern.

Limitasi:

  • tidak semua environment lama mendukung,
  • timeout signal juga one-shot,
  • hanya satu source cancellation jika dipakai sendiri.

Biasanya kamu perlu menggabungkan timeout dengan signal lain.


7. Composing Signals with AbortSignal.any()

Satu request sering punya beberapa alasan batal:

  • component unmount,
  • user cancel,
  • timeout,
  • route navigation,
  • logout.

Dengan AbortSignal.any():

const signal = AbortSignal.any([
  componentSignal,
  userCancelSignal,
  AbortSignal.timeout(10_000),
])

await fetch('/api/report', { signal })

Jika salah satu signal abort, combined signal abort.

Fallback helper:

export function anySignal(signals: AbortSignal[]): AbortSignal {
  if (typeof AbortSignal.any === 'function') {
    return AbortSignal.any(signals)
  }

  const controller = new AbortController()

  const abort = (signal: AbortSignal) => {
    if (!controller.signal.aborted) {
      controller.abort(signal.reason)
    }
  }

  for (const signal of signals) {
    if (signal.aborted) {
      abort(signal)
      break
    }

    signal.addEventListener('abort', () => abort(signal), { once: true })
  }

  return controller.signal
}

8. Manual Timeout Helper

Untuk environment yang belum punya AbortSignal.timeout():

export function timeoutSignal(ms: number, reason = 'timeout'): AbortSignal {
  const controller = new AbortController()

  const timer = window.setTimeout(() => {
    controller.abort(reason)
  }, ms)

  controller.signal.addEventListener(
    'abort',
    () => {
      window.clearTimeout(timer)
    },
    { once: true },
  )

  return controller.signal
}

Namun helper ini tidak memberi cara membatalkan timer dari luar kecuali signal abort sendiri.

Versi operation helper yang lebih eksplisit:

export async function withTimeout<T>(
  ms: number,
  run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
  const controller = new AbortController()
  const timer = window.setTimeout(() => controller.abort('timeout'), ms)

  try {
    return await run(controller.signal)
  } finally {
    window.clearTimeout(timer)
  }
}

Usage:

const data = await withTimeout(5_000, (signal) =>
  fetch('/api/me', { signal }).then((r) => r.json()),
)

9. Deadline vs Timeout

Timeout per attempt:

attempt 1: 5s
attempt 2: 5s
attempt 3: 5s
Total: maybe 15s+backoff

Deadline total:

total budget: 8s
attempts must fit inside 8s

For interactive UI, deadline is often better.

class Deadline {
  private readonly endsAt: number

  constructor(durationMs: number) {
    this.endsAt = performance.now() + durationMs
  }

  remainingMs() {
    return Math.max(0, this.endsAt - performance.now())
  }

  signal(): AbortSignal {
    return AbortSignal.timeout(this.remainingMs())
  }
}

Usage with retry:

const deadline = new Deadline(8_000)

for (let attempt = 1; attempt <= 3; attempt++) {
  const remaining = deadline.remainingMs()
  if (remaining <= 0) throw new Error('Deadline exceeded')

  try {
    return await fetch('/api/search', {
      signal: AbortSignal.timeout(remaining),
    })
  } catch (error) {
    if (attempt === 3) throw error
    await sleep(Math.min(250 * attempt, deadline.remainingMs()))
  }
}

Caveat: AbortSignal.timeout(0) means immediate abort. Guard zero/negative budgets intentionally.


10. React useEffect Cleanup Pattern

Canonical pattern:

function UserProfile({ userId }: { userId: string }) {
  const [state, setState] = React.useState<UserState>({ status: 'idle' })

  React.useEffect(() => {
    const controller = new AbortController()

    async function run() {
      setState({ status: 'loading' })

      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        })
        const user = await readJsonResponse<User>(response)

        if (controller.signal.aborted) return

        setState({ status: 'success', user })
      } catch (error) {
        if (controller.signal.aborted) return
        setState({ status: 'error', error })
      }
    }

    run()

    return () => {
      controller.abort('component-disposed')
    }
  }, [userId])

  return renderUserState(state)
}

Why check signal.aborted after parsing?

Because abort can happen after response arrives but before parse or before state commit.

The authority check belongs near commit.


11. Sequence Guard vs Abort

Abort stops work when possible.

Sequence guard prevents stale commit even if abort does not stop everything.

function useSearch(query: string) {
  const [results, setResults] = React.useState<SearchResult[]>([])
  const sequenceRef = React.useRef(0)

  React.useEffect(() => {
    const sequence = ++sequenceRef.current
    const controller = new AbortController()

    async function run() {
      const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: controller.signal,
      })
      const data = await readJsonResponse<SearchResult[]>(response)

      if (controller.signal.aborted) return
      if (sequence !== sequenceRef.current) return

      setResults(data ?? [])
    }

    run().catch((error) => {
      if (!controller.signal.aborted && sequence === sequenceRef.current) {
        console.error(error)
      }
    })

    return () => controller.abort('query-changed')
  }, [query])

  return results
}

For highly interactive search, use both:

  • abort old request to save resources,
  • sequence guard to protect correctness.

12. Abort and Body Consumption

Abort can affect body reading too.

const controller = new AbortController()
const response = await fetch('/api/large-report', {
  signal: controller.signal,
})

const reader = response.body!.getReader()

setTimeout(() => controller.abort('too-slow'), 1_000)

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  process(value)
}

Depending on timing, abort may happen:

  • before request starts,
  • while waiting for headers,
  • while reading body,
  • after body fully read,
  • after app already committed state.

A robust app handles all of these.


13. Cancellation-Aware Stream Reader

export async function readStreamWithSignal(
  response: Response,
  options: {
    signal: AbortSignal
    onChunk: (chunk: Uint8Array) => void
  },
) {
  if (!response.body) return

  const reader = response.body.getReader()

  const abort = () => {
    reader.cancel(options.signal.reason).catch(() => {
      // ignore cancellation cleanup failure
    })
  }

  if (options.signal.aborted) {
    abort()
    return
  }

  options.signal.addEventListener('abort', abort, { once: true })

  try {
    while (true) {
      if (options.signal.aborted) return

      const { done, value } = await reader.read()
      if (done) return

      options.onChunk(value)
    }
  } finally {
    options.signal.removeEventListener('abort', abort)
    reader.releaseLock()
  }
}

Stream cancellation should release ownership.


14. Abort Error Detection

Naive:

catch (error) {
  if ((error as Error).name === 'AbortError') return
  throw error
}

This often works, but not all abort-like paths look identical across abstraction layers.

Better if you own the signal:

catch (error) {
  if (signal.aborted) return
  throw error
}

For libraries/wrappers:

export function isAbortLikeError(error: unknown): boolean {
  return (
    error instanceof DOMException &&
    (error.name === 'AbortError' || error.name === 'TimeoutError')
  )
}

But prefer context-aware check:

if (signal.aborted) {
  // We know this operation was intentionally cancelled.
}

15. Building a Cancellation-Aware Fetch Client

type RequestPolicy = {
  timeoutMs?: number
  signal?: AbortSignal
}

export async function fetchWithPolicy(
  input: RequestInfo | URL,
  init: RequestInit & RequestPolicy = {},
): Promise<Response> {
  const { timeoutMs, signal, ...requestInit } = init

  const signals: AbortSignal[] = []

  if (signal) signals.push(signal)
  if (timeoutMs != null) signals.push(AbortSignal.timeout(timeoutMs))

  const effectiveSignal =
    signals.length === 0
      ? undefined
      : signals.length === 1
        ? signals[0]
        : AbortSignal.any(signals)

  return fetch(input, {
    ...requestInit,
    signal: effectiveSignal,
  })
}

Usage:

const response = await fetchWithPolicy('/api/me', {
  timeoutMs: 5_000,
  signal: routeSignal,
  headers: { Accept: 'application/json' },
})

If supporting older browsers, use wrappers from earlier section for any and timeout.


16. Cancellation-Aware JSON Client

export async function getJson<T>(
  path: string,
  options: RequestInit & {
    timeoutMs?: number
    signal?: AbortSignal
    schema?: { parse(value: unknown): T }
  } = {},
): Promise<T | undefined> {
  const { schema, ...init } = options

  const response = await fetchWithPolicy(path, {
    ...init,
    headers: {
      Accept: 'application/json, application/problem+json',
      ...init.headers,
    },
  })

  const value = await readJsonResponse<T>(response, { schema })

  if (options.signal?.aborted) {
    throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
  }

  return value
}

One subtle issue: if fetchWithPolicy creates an internal timeout signal, the caller's options.signal may not show timeout abort. In production, return or attach effective signal to request context if later stages must inspect it.

Better internal shape:

type EffectiveRequest = {
  signal?: AbortSignal
  timeoutMs?: number
}

Keep effective signal in a local variable and check that after parse.


17. Request Context Pattern

type RequestContext = {
  operation: string
  signal: AbortSignal
  startedAt: number
  deadlineMs?: number
}

function createRequestContext(input: {
  operation: string
  signal?: AbortSignal
  timeoutMs?: number
}): RequestContext {
  const signals: AbortSignal[] = []
  if (input.signal) signals.push(input.signal)
  if (input.timeoutMs != null) signals.push(AbortSignal.timeout(input.timeoutMs))

  return {
    operation: input.operation,
    signal:
      signals.length === 0
        ? new AbortController().signal
        : signals.length === 1
          ? signals[0]
          : AbortSignal.any(signals),
    startedAt: performance.now(),
    deadlineMs: input.timeoutMs,
  }
}

Then:

async function getUser(userId: string, parentSignal?: AbortSignal) {
  const context = createRequestContext({
    operation: 'users.get',
    signal: parentSignal,
    timeoutMs: 5_000,
  })

  const response = await fetch(`/api/users/${userId}`, {
    signal: context.signal,
    headers: { Accept: 'application/json' },
  })

  const user = await readJsonResponse<User>(response)

  if (context.signal.aborted) {
    throw context.signal.reason
  }

  return user
}

This gives every request a lifecycle owner.


18. Mutation Cancellation Is Not Undo

Critical principle:

Aborting a fetch does not guarantee the server did not receive or commit the mutation.

Example:

const controller = new AbortController()

const promise = fetch('/api/payments', {
  method: 'POST',
  body: JSON.stringify({ amount: 100 }),
  signal: controller.signal,
})

controller.abort('user-cancelled')

Possible realities:

RealityWhat happened
request not sentno server side effect
request partially sentserver may reject or still process
request fully sent, response not receivedside effect may have committed
response received but app aborted before parsingside effect committed; UI ignored response

Therefore mutation cancellation must be modeled as:

  • stop waiting,
  • stop UI ownership,
  • maybe send compensating command,
  • maybe poll status,
  • maybe rely on idempotency key,
  • maybe show “status unknown”.

Do not say “cancelled” for a mutation unless server confirmed cancellation.


19. Mutation Ambiguity State Machine

For important writes, introduce operation id/idempotency key:

await fetch('/api/payments', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify(command),
  signal,
})

Then if timeout happens:

const status = await fetch(`/api/operations/${operationId}`).then((r) => r.json())

A cancellation-aware mutation does not lie to the user.


20. UI Language for Cancelled Work

For read operations:

  • “Search cancelled” usually unnecessary.
  • Just show the new search/loading state.

For user-initiated long job:

  • “Upload cancelled” if client cancelled before completion.
  • “Stopping…” if server cancellation is requested.
  • “Status unknown. Checking…” if network timeout after command sent.

For sensitive operations:

  • avoid “Cancelled” unless confirmed.
  • prefer “We could not confirm the result. Please check status.”

This is especially important for payments, regulatory submissions, enforcement actions, approvals, and irreversible workflows.


21. Cancellation in Query Libraries

Modern server-state libraries commonly pass an AbortSignal to query functions.

Conceptual example:

useQuery({
  queryKey: ['user', userId],
  queryFn: ({ signal }) =>
    getJson<User>(`/api/users/${userId}`, { signal }),
})

Your API client must accept signal; otherwise the query library can mark a query obsolete but cannot stop transport.

Good query function:

async function fetchUser(userId: string, signal?: AbortSignal) {
  const response = await fetch(`/api/users/${userId}`, { signal })
  return readJsonResponse<User>(response)
}

Bad query function:

async function fetchUser(userId: string) {
  return fetch(`/api/users/${userId}`).then((r) => r.json())
}

The second ignores lifecycle ownership.


22. Route-Level Cancellation

Route loaders/actions in modern frameworks often receive request/signal-like lifecycle context.

Pattern:

export async function loader({ params, request }: LoaderArgs) {
  const response = await fetch(`/api/users/${params.userId}`, {
    signal: request.signal,
    headers: { Accept: 'application/json' },
  })

  return readJsonResponse<User>(response)
}

If user navigates away, the route system can abort pending work.

But the same mutation rule applies:

  • aborting a loader read is usually safe,
  • aborting an action write does not guarantee the write did not happen.

23. Cancellation and Retry

Retry loop must respect signal.

Bad:

for (let i = 0; i < 3; i++) {
  try {
    return await fetch(url)
  } catch {
    await sleep(1_000)
  }
}

If user navigates away, this loop continues.

Better:

function sleep(ms: number, signal?: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason)
      return
    }

    const timer = window.setTimeout(resolve, ms)

    signal?.addEventListener(
      'abort',
      () => {
        window.clearTimeout(timer)
        reject(signal.reason)
      },
      { once: true },
    )
  })
}

async function retryingFetch(
  url: string,
  options: RequestInit & { signal?: AbortSignal } = {},
) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    if (options.signal?.aborted) throw options.signal.reason

    try {
      return await fetch(url, options)
    } catch (error) {
      if (options.signal?.aborted) throw error
      if (attempt === 3) throw error
      await sleep(250 * attempt, options.signal)
    }
  }

  throw new Error('unreachable')
}

Retry is part of the same cancellation domain.


24. Cancellation and Concurrency Control

Autocomplete example:

class LatestOnlyRunner {
  private controller: AbortController | null = null
  private sequence = 0

  async run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T | undefined> {
    this.controller?.abort('superseded')

    const controller = new AbortController()
    const sequence = ++this.sequence
    this.controller = controller

    try {
      const result = await task(controller.signal)
      if (controller.signal.aborted) return undefined
      if (sequence !== this.sequence) return undefined
      return result
    } catch (error) {
      if (controller.signal.aborted) return undefined
      throw error
    }
  }

  cancel(reason = 'manual-cancel') {
    this.controller?.abort(reason)
  }
}

Usage:

const runner = new LatestOnlyRunner()

runner.run((signal) =>
  getJson<SearchResult[]>(`/api/search?q=${q}`, { signal, timeoutMs: 1_000 }),
)

This pattern encodes:

  • latest request has authority,
  • previous request loses authority,
  • previous transport is aborted when possible.

25. In-Flight Request Registry

For complex apps, maintain a registry by operation/key.

type InflightEntry = {
  controller: AbortController
  startedAt: number
  operation: string
}

class InflightRegistry {
  private readonly entries = new Map<string, InflightEntry>()

  start(key: string, operation: string): AbortSignal {
    this.cancel(key, 'superseded')

    const controller = new AbortController()
    this.entries.set(key, {
      controller,
      operation,
      startedAt: performance.now(),
    })

    controller.signal.addEventListener(
      'abort',
      () => this.entries.delete(key),
      { once: true },
    )

    return controller.signal
  }

  finish(key: string) {
    this.entries.delete(key)
  }

  cancel(key: string, reason = 'cancelled') {
    const entry = this.entries.get(key)
    if (!entry) return
    entry.controller.abort(reason)
    this.entries.delete(key)
  }

  cancelAll(reason = 'global-cancel') {
    for (const [key, entry] of this.entries) {
      entry.controller.abort(reason)
      this.entries.delete(key)
    }
  }
}

Use cases:

  • cancel all on logout,
  • latest-only per search key,
  • limit concurrent exports,
  • cancel background prefetch on interaction.

Be careful not to rebuild what query libraries already provide unless you need cross-cutting ownership.


26. Cancellation and Service Workers

Abort from page fetch can affect request lifetime as seen by browser, but service workers and caches may introduce extra layers.

Important questions:

  • Does the service worker pass the signal to its own fetch?
  • Does it return cached response immediately?
  • Does it continue background update after client abort?
  • Does cache write happen after UI no longer needs it?

Service worker strategy must define whether cancellation is client-local or network-wide.

Example principle:

UI abort should stop UI-owned work; background cache refresh may continue only if explicitly owned by service worker policy.


27. Observability for Cancellation

Do not log every abort as error.

Metric categories:

CategoryExample label
user/supersededcancel.superseded
navigation/unmountcancel.lifecycle
timeoutcancel.timeout
budget/concurrencycancel.resource_budget
global logout/resetcancel.global_reset
unknown abortcancel.unknown

For timeout, record:

  • operation,
  • timeout budget,
  • elapsed time,
  • attempt count,
  • connection info if available,
  • response phase if known: before headers vs body parsing,
  • route/feature,
  • trace id.

For manual/user cancellation, usually no error log.


28. Testing Cancellation

Test cases:

CaseExpected
abort before fetchimmediate reject/no commit
abort during requestno state commit
abort during body parseno state commit
dependency changeold response cannot overwrite new
timeouttimeout path surfaced
manual cancel mutationstatus unknown unless server confirms
retry + abort during sleepretry loop stops
stream + abortreader cancelled/released
query signal passedunderlying fetch observes abort

Example test:

it('does not commit stale result after abort', async () => {
  const controller = new AbortController()
  const commit = vi.fn()

  const promise = fetch('/api/slow', { signal: controller.signal })
    .then((r) => readJsonResponse(r))
    .then((data) => {
      if (!controller.signal.aborted) commit(data)
    })

  controller.abort('test')

  await expect(promise).resolves.toBeUndefined()
  expect(commit).not.toHaveBeenCalled()
})

For MSW, simulate delayed response and assert cleanup.


29. Production Failure Modes

Failure modeRoot causePrevention
stale search resultsold response commits lateabort + sequence guard
memory leakstream reader not releasedfinally release/cancel
spinner foreverno timeout/deadlineoperation budget
retry after navigationretry loop ignores signalcancellation-aware sleep
noisy error logsabort logged as errorcancellation taxonomy
false mutation cancelclient abort interpreted as undooperation status/idempotency
lost timeout contextinternal signal hiddenrequest context/effective signal
aborted signal reusedcontroller stored globallyone controller per attempt
render stormstream chunk commits too oftenbatching/throttle
background request leakprefetch not cancelledownership policy

30. Practical Cancellation Policy

Define defaults per operation:

OperationTimeoutCancellationCommit rule
autocompleteshortlatest-onlylatest sequence only
route readmediumabort on navigationroute owner only
detail refreshmediumabort on unmount/key changematching resource key
background prefetchshort/lowabort on user interactioncache only if still useful
form submitmediumuser may stop waitingdo not claim undo
critical mutationlonger + status checkcancellation creates unknownconfirm status
file uploadprogress-basedexplicit user cancelupload protocol decides
streaming logsno fixed or heartbeatabort on close/unmountbatched append only

31. Checklist for Fetch Cancellation

Before shipping a networked React feature, answer:

  • Who owns this request lifecycle?
  • What aborts it?
  • What is its timeout/deadline?
  • Is timeout per attempt or total operation?
  • Does retry respect signal?
  • Can stale response commit state?
  • Is there a sequence/resource key guard?
  • Are aborts logged separately from failures?
  • Does stream reader release lock on abort?
  • Does parser stop/ignore commit after abort?
  • For mutation, what does cancel mean?
  • Is idempotency needed?
  • Is status check needed after timeout?
  • Are user messages truthful?

32. Ringkasan

AbortController adalah primitive kecil dengan konsekuensi arsitektural besar.

Yang harus diingat:

  • cancellation menyatakan result kehilangan authority.
  • AbortSignal one-shot; jangan reuse untuk attempt baru.
  • timeout adalah policy/budget, bukan angka random.
  • AbortSignal.timeout() dan AbortSignal.any() membantu composition di browser modern.
  • cleanup React harus abort request dan mencegah stale commit.
  • abort tidak selalu menghentikan server work.
  • mutation cancellation bukan undo.
  • retry, stream, parser, query function, dan route loader harus signal-aware.
  • log timeout sebagai reliability signal, tetapi jangan log lifecycle abort sebagai error.

React app yang matang bukan app yang tidak pernah membatalkan request. React app yang matang tahu kapan result sudah tidak punya hak lagi untuk mengubah UI, cache, atau workflow.

Part berikutnya membahas error taxonomy: network error, abort, timeout, HTTP error, parse error, validation error, domain error, conflict, rate limit, dan bagaimana UI + telemetry harus membedakannya.


Referensi

  • MDN — AbortController.abort(): https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
  • MDN — AbortSignal: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
  • MDN — AbortSignal.timeout(): https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static
  • MDN — Fetch API: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
  • MDN — Using the Fetch API: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
  • MDN — ReadableStream.getReader(): https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/getReader
  • WHATWG — Fetch Standard: https://fetch.spec.whatwg.org/
Lesson Recap

You just completed lesson 12 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.