Skip to content

AdvancedAbort & Deduplication

Abort & Deduplication

Automatically cancel duplicate in-flight requests and manually abort with AbortController.

3 min readboth modes

Overview

When users interact quickly — typing in a search box, clicking pagination rapidly, or switching filters — multiple requests for the same endpoint can be in flight simultaneously. Courier can automatically abort the previous request when a new one starts for the same endpoint.

Enable this by setting deduplicate: true in your source options. Courier will track active requests and cancel duplicates before they complete.

Enabling deduplication

Set deduplicate: true in defineApi options. This applies to all endpoints in the source:

api/index.ts
import { defineApi } from "@noverax/courier/define"

const api = defineApi({
  baseURL: process.env.API_URL ?? "http://localhost:4000",
  deduplicate: true,
  groups: { users, posts },
})

Where deduplication runs

Deduplication cancels a request its own user no longer needs, so it runs where one user is: in the browser, or in a React Native app. A direct call made there uses the source's deduplicate, and so does a proxy client created with source. A proxy client created with a boundary carries no source options — pass deduplicate: true to createProxyCaller instead.

On a server the option has no effect, for direct calls and for requests arriving at the proxy alike: there, requests from different users reach the same endpoint under the same key, and one user's request would cancel another's.

Only reads are cancelled

Deduplication cancels reads only: endpoints declared with get, head, options or query, or marked .reads(). A write that a newer one superseded has usually reached the server and been applied, so cancelling it would tell its caller it failed when it did not — two concurrent creates to one path both complete. A proxy client follows the same rule whether it was created with source or with a boundary: the boundary declares each HTTP endpoint a "query" or a "mutation", and only a query is cancelled.

Deduplication key

The dedup key is computed as METHOD:origin/path. The query string and hash are intentionally ignored — this means requests to the same endpoint with different query parameters are treated as duplicates:

ts
// Dedup key format: METHOD:origin/path
// Query string and hash are ignored

// These two requests share the same dedup key (GET:https://api.example.com/users):
caller.users.getAll({ page: 1 })  // ← aborted (status 499)
caller.users.getAll({ page: 2 })  // ← executes

// These have different keys (different paths):
caller.users.getById({ id: 1 })   // GET:https://api.example.com/users/1
caller.users.getById({ id: 2 })   // GET:https://api.example.com/users/2

Why ignore query params?

In search-as-you-type scenarios, each keystroke sends a new query to the same endpoint. You typically only care about the latest results. By ignoring query parameters in the dedup key, the previous search is automatically cancelled.

Abort behavior

When a duplicate request is detected:

  1. The previous request is aborted and throws an HTTPError with status 499.
  2. The new request proceeds normally.
  3. If middleware is defined, the error propagates through the middleware chain.

Handling abort errors

Aborted requests throw with status 499. In most cases you want to silently ignore these errors since they represent intentional cancellation:

components/search-users.tsx
"use client"

import { q } from "@/lib/query"

export function SearchUsers() {
  const [query, setQuery] = useState("")

  // With deduplicate enabled, rapid typing aborts previous searches
  const { data, error } = q.users.useQuery("search", {
    variables: { q: query },
    enabled: query.length > 0,
  })

  // Filter out abort errors (status 499) in error handling
  useEffect(() => {
    if (error && "response" in error) {
      const status = (error as any).response?.status
      if (status === 499) return // Ignore aborted requests
      console.error("Search failed:", error)
    }
  }, [error])

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {data?.map((user) => <p key={user.id}>{user.name}</p>)}
    </div>
  )
}

Manual abort with AbortController

You can also manually abort requests by passing a signal in the call options. This works independently of the deduplication feature — you can use one or both:

components/cancellable-request.tsx
"use client"

import { useEffect, useRef } from "react"

import { caller } from "@/lib/caller"

export function CancellableRequest() {
  const controllerRef = useRef<AbortController | null>(null)

  async function fetchData() {
    // Cancel any previous request
    controllerRef.current?.abort()
    controllerRef.current = new AbortController()

    try {
      const data = await caller.reports
        .with({ signal: controllerRef.current.signal })
        .generate({ type: "monthly" })

      console.log("Report:", data)
    } catch (error) {
      if (error instanceof DOMException && error.name === "AbortError") {
        console.log("Request was cancelled")
        return
      }
      throw error
    }
  }

  // Cleanup on unmount
  useEffect(() => {
    return () => controllerRef.current?.abort()
  }, [])

  return (
    <div>
      <button onClick={fetchData}>Generate Report</button>
      <button onClick={() => controllerRef.current?.abort()}>Cancel</button>
    </div>
  )
}

Signal merging

If you use both deduplicate: true and a manual signal, Courier combines them using AbortSignal.any(). The request aborts if either signal fires.

How it works internally

Deduplication is implemented inside the HttpClient. Understanding the internals helps when debugging abort-related issues:

ts
// How deduplication works internally:

// 1. Before each read:
//    - Computes dedup key: METHOD:origin/path
//    - If a request with the same key exists, aborts it (status 499)
//    - Stores new AbortController in currentExecutingRequests map

// 2. After a request completes:
//    - Removes the entry from currentExecutingRequests

// 3. Signal merging:
//    - If you also pass a manual signal, both signals are combined
//    - Uses AbortSignal.any() — request aborts if either signal fires

Next steps

  • Middleware — use error-reporting middleware to filter abort errors from monitoring
  • Options — signal, timeout, and other request-level options
  • Streaming — receive responses as ReadableStreams for SSE and large downloads