Overview
Calls that overlap are handled in three ways:
- Identical reads in flight together reach the API once. Two components that read the same thing at the same moment — a header and a page both asking for the current user — send one request, and each gets the answer. This is on by default, wherever the call runs.
- A newer read can cancel the one it replaces. In a search box each keystroke makes the previous search pointless; an API names such endpoints in
cancelPrevious. - Any call can be aborted with an
AbortSignalof your own.
Identical reads share one request
While a read is in flight, an identical read joins it rather than sending a request of its own. Every call that joined gets the answer — the same status, headers and body — as a copy of its own: one call editing what it got changes nothing for another.
// Both calls start before either answer arrives: the API receives one request.
const [user, sameUser] = await Promise.all([api.users.me(), api.users.me()])Two requests are identical only when everything they send is: the method, the address with its query, every header, and the body. Whatever tells two callers apart therefore keeps their requests apart:
- different variables — page 1 and page 2 are two requests;
- different users — their cookies,
Authorizationand address differ; - different requests being served — each carries its own forwarded request id.
On a server, one user's read is never answered with another's.
What is never shared:
- Writes. Two orders are two orders. A read is an endpoint addressed with
get,headorquery, or one marked.reads()— a search sent as a POST is joined too. - A request whose body is not text — a file, a form, a stream.
- A call that follows its upload or download progress, since each one reports its own.
Studio shows each call, and marks one whose answer came from another call's request shared: its inspector says how — joined in flight, or read earlier in the render — and opens the call whose request answered it. Two components reading the user at once are two calls and one request. Every figure about the API — latency, pace, failures, statuses and bytes, in the Timeline, Analytics, the Explorer, the Audit, the status bar, a saved session and the editor's hints — counts the request alone, and names the calls beside it where the two differ, as 2 calls · 1 request; a failed or slow request is told in one toast, however many calls it answered. A HAR or a Postman collection holds the requests; a saved session and its JSON export keep every call, marked.
A request is joined only until its answer arrives. A read made after that sends a request of its own; to reuse an answer, keep it: React Query or SWR in the browser, the cache middleware on a server. In Next.js, createServerCaller already reads once per render: a page's generateMetadata and its body share the read even when one runs after the other.
Leaving early
A call that stops waiting — its signal aborted, its timeout reached — rejects alone, and the others still get the answer. The request itself is cancelled only when every call waiting for it has left.
Sending a call alone
Pass coalesce: false to send every call alone, for a whole API or for one call:
import { defineApi } from "@noverax/courier/define"
export const api = defineApi({
name: "telemetry",
baseURL: "https://telemetry.example.com",
coalesce: false,
endpoints: { /* ... */ },
})// Each poll asks the server anew, even while the last one is in flight
const status = await caller.jobs.with({ coalesce: false }).status({ id })Cancelling the previous call
A search as the user types makes every earlier search pointless the moment a new key is pressed. List such reads by their path in cancelPrevious, and a newer call to one of them cancels the call still in flight — whatever its variables were:
import { defineApi } from "@noverax/courier/define"
export const api = defineApi({
name: "accounts",
baseURL: process.env.API_URL ?? "http://localhost:4000",
cancelPrevious: ["users.search", "geo.suggest"],
groups: { users, geo },
})The cancelled call rejects with an HTTPError of status 499; the newer one proceeds. A newer call identical to the one it cancels — the same search typed again — is answered by the request already in flight, so no second request goes out. A middleware sees the error as it would any other, and the retry middleware never retries it.
Only reads are cancelled
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. cancelPrevious names reads only: a path the API does not define, or one that writes, throws ConfigurationError CANCEL_PREVIOUS_INVALID where the API is defined.
Where it runs
A newer call cancels an older one where one user is: in the browser, or in a React Native app. A direct call made there uses the API's cancelPrevious, and so does a proxy client, from the routes Courier writes from the API; pass cancelPrevious to createProxyCaller to name others.
On a server the option has no effect, for direct calls and for requests arriving at the proxy alike: there, calls from different users reach the same endpoint, and one user's search would cancel another's.
Handling the cancelled call
A cancelled call is no failure the user needs to hear about:
"use client"
import { isHTTPError } from "@noverax/courier"
import { useEffect, useState } from "react"
import { q } from "@/lib/query"
export function SearchUsers() {
const [query, setQuery] = useState("")
// Each keystroke cancels the search still in flight
const { data, error } = q.users.useQuery("search", {
variables: { q: query },
enabled: query.length > 0,
})
useEffect(() => {
if (!error) return
if (isHTTPError(error) && error.status === 499) return // superseded by a newer search
console.error("Search failed:", error)
}, [error])
return (
<div>
<input value={query} onChange={(event) => setQuery(event.target.value)} />
{data?.map((user) => <p key={user.id}>{user.name}</p>)}
</div>
)
}Aborting a call yourself
Pass a signal with the call. It works on any call, alongside both of the above:
"use client"
import { useEffect, useRef } from "react"
import { caller } from "@/lib/caller"
export function CancellableReport() {
const controllerRef = useRef<AbortController | null>(null)
async function generate() {
controllerRef.current?.abort()
controllerRef.current = new AbortController()
try {
const report = await caller.reports
.with({ signal: controllerRef.current.signal })
.generate({ type: "monthly" })
console.log("Report:", report)
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return
throw error
}
}
useEffect(() => () => controllerRef.current?.abort(), [])
return (
<div>
<button onClick={generate}>Generate report</button>
<button onClick={() => controllerRef.current?.abort()}>Cancel</button>
</div>
)
}A call aborted through your signal rejects with the reason you gave, as fetch does: a DOMException named AbortError unless you passed another. On an endpoint listed in cancelPrevious, the call stops at whichever comes first — your signal, or a newer call.
Next steps
- Middleware — keep cancelled calls out of error reporting
- Options —
signal,timeout,coalesceand the other per-call options - Built-in middleware — the cache that keeps an answer after it arrives