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:
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:
// 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/2Why 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:
- The previous request is aborted and throws an
HTTPErrorwith status499. - The new request proceeds normally.
- 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:
"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:
"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:
// 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 firesNext 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