Skip to content

AdvancedDeduplication & Abort

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.

5 min readUpdated both modes

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.

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 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:

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:

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 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:

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:

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 — keep cancelled calls out of error reporting
  • Options — signal, timeout, coalesce and the other per-call options
  • Built-in middleware — the cache that keeps an answer after it arrives