← All writing

Technical note · 2026-09-14

Out-of-order requests: accepting only the latest result

One technical problem: deciding which asynchronous result still owns the UI. Handle repeated selections, refreshes, stale failures and teardown with request generations.

  • Vue
  • Async races
  • AbortController
  • State management

Related project: Business communication and after-sales collaboration. This article draws on session-switching work; the simplified example adds request generations beyond the project’s session-ID checks.

A successful response can still be the wrong response

A chat panel tracks the active session, messages, drafts, pagination and loading state. Suppose a user opens A and immediately selects B. B returns first, then A. If both callbacks replace a shared list, A wins despite B being the active conversation.

The invariant is simple: only the currently valid request may update the current panel. Transport success does not establish that ownership.

Cancellation reduces work; ownership guards state

Fetch accepts an AbortSignal for cancelling requests and response-body consumption, as described in MDN’s Fetch documentation. However, an adapter, cached Promise or later asynchronous transformation may not honor that signal.

The project combines cancellation with session checks after asynchronous steps. A more general design must also handle A → B → A and two successive refreshes of A. In both cases an old request has the same session ID as the current panel.

An incrementing generation identifies a particular load intent, not just its session.

A small framework-independent loader

This example replaces the initial message list. The injected read function includes fetching and every asynchronous transformation; it is not a new business API. emit updates state synchronously. Create one loader per independent chat panel.

type State = {
  sessionId: string
  status: 'loading' | 'ready' | 'error'
  messages: string[]
  error?: string
}

type ReadMessages = (
  sessionId: string,
  signal: AbortSignal
) => Promise<string[]>

export function createMessageLoader(
  read: ReadMessages,
  emit: (state: State) => void
) {
  let revision = 0
  let controller: AbortController | undefined
  let disposed = false

  return {
    async load(sessionId: string) {
      if (disposed) return
      // Repeated loads of one session still need separate generations.
      const currentRevision = ++revision
      controller?.abort()
      const current = new AbortController()
      controller = current
      const isCurrent = () =>
        !disposed && currentRevision === revision

      emit({ sessionId, status: 'loading', messages: [] })
      try {
        // read includes fetching and all asynchronous transformations.
        const messages = await read(sessionId, current.signal)
        if (!isCurrent()) return
        emit({ sessionId, status: 'ready', messages })
      } catch (error) {
        if (!isCurrent() || current.signal.aborted) return
        emit({
          sessionId,
          status: 'error',
          messages: [],
          error: error instanceof Error
            ? error.message : 'Message loading failed',
        })
      }
    },
    dispose() {
      disposed = true
      revision += 1
      controller?.abort()
    },
  }
}

Call load from the relevant UI lifecycle and call dispose when the panel is destroyed. Keep the instance stable across React renders or Vue updates.

Notice that an obsolete request does not unconditionally clear loading in finally. Stale failures and loading transitions can corrupt a panel even when the message list itself is protected.

A generation belongs to one competing intent

This loader applies when a new request replaces an old result: chat initial loading, search suggestions, detail views or previews. Each independent panel needs its own generation scope.

Do not share a global counter between unrelated panels. Nor should every concurrent history page be discarded except the last; merging results is a separate problem with a different contract.

Tests that reveal the race

Use deferred Promises to control completion order:

  • Complete B before A and verify that A cannot replace B.
  • Complete A1 after A2 in an A1 → B → A2 sequence.
  • Reject an old refresh after the newer refresh succeeds.
  • Destroy the panel during fetching or transformation.
  • Use a reader that ignores abort and verify that generations still protect state.

The same pattern applies to search suggestions, detail panels and attachment previews: acceptance follows current user intent, not completion order.