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.