# Deduplication & Abort

Identical reads in flight share one request, a newer search cancels the one it replaces, and any call can be aborted with a signal.

## 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 `AbortSignal` of 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.

```ts title="components/header.tsx"
// 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, `Authorization` and 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`, `head` or `query`, 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](/docs/react-query/use-query) or SWR in the browser, the [cache middleware](/docs/middleware/built-in) on a server. In Next.js, [`createServerCaller`](/docs/frameworks/nextjs) 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:

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

export const api = defineApi({
  name: "telemetry",
  baseURL: "https://telemetry.example.com",
  coalesce: false,
  endpoints: { /* ... */ },
})
```

```ts
// 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:

```ts title="api/index.ts"
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](/docs/middleware/built-in) 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`](/docs/api/create-proxy-caller) 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:

```tsx title="components/search-users.tsx"
"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:

```tsx title="components/cancellable-report.tsx"
"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](/docs/advanced/middleware) — keep cancelled calls out of error reporting
- [Options](/docs/advanced/options) — `signal`, `timeout`, `coalesce` and the other per-call options
- [Built-in middleware](/docs/middleware/built-in) — the cache that keeps an answer after it arrives
