race-safe-task
v0.1.0
Published
Tiny TypeScript utility to prevent stale async results and race conditions in UI code.
Maintainers
Readme
race-safe-task
Tiny TypeScript utility to prevent stale async results and race conditions in UI code.
import { createRaceSafeTask } from 'race-safe-task'
const searchUsers = createRaceSafeTask(async ({ signal }, query: string) => {
const response = await fetch(`/api/users?q=${query}`, { signal })
return response.json()
})
const result = await searchUsers.run('vue')
if (result.ok) {
renderUsers(result.data)
}The problem
UI code often starts multiple async operations quickly:
- live search input
- autocomplete
- filters
- pagination
- route changes
- tab switching
- autosave
The bug appears when an older request resolves after a newer one:
User types: v → vu → vue
Requests: #1 #2 #3
Responses: #3 #2 #1
UI shows: stale result from #1 ❌race-safe-task makes this pattern safe by combining:
- latest-run tracking
AbortController- stale result detection
- timeout support
- typed result objects
- framework-agnostic API
- zero runtime dependencies
Installation
npm install race-safe-taskWhy not just use debounce?
Debounce reduces how often a function runs. It does not fully solve out-of-order async completion.
You can still get stale results when:
- the user changes filters quickly;
- network timing is unpredictable;
- a slow previous request resolves after a fast later request;
- the async function cannot be fully canceled;
- a component/page state changes while a task is still running.
race-safe-task makes sure only the latest run is allowed to update state.
Quick start
import { createRaceSafeTask } from 'race-safe-task'
const task = createRaceSafeTask(async ({ signal }, id: string) => {
const response = await fetch(`/api/products/${id}`, { signal })
if (!response.ok) {
throw new Error('Failed to load product')
}
return response.json() as Promise<Product>
})
const result = await task.run('product-1')
if (result.ok) {
console.log(result.data)
}API design
The handler receives a context object as the first argument:
const task = createRaceSafeTask(async (context, arg1, arg2) => {
context.signal // AbortSignal
context.runId // current run id
context.isLatest() // true if this run is still the latest
context.throwIfStale()
})Then task.run() receives only your business arguments:
task.run(arg1, arg2)Handling results
run() never throws for normal task failures. It returns a typed result object:
const result = await task.run('vue')
switch (result.status) {
case 'success':
console.log(result.data)
break
case 'stale':
// Old result ignored.
break
case 'aborted':
// Request was canceled.
break
case 'error':
console.error(result.error)
break
}If you prefer throwing behavior, use runOrThrow():
const data = await task.runOrThrow('vue')Live search example
import { createRaceSafeTask } from 'race-safe-task'
const searchTask = createRaceSafeTask(async ({ signal }, query: string) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal,
})
return response.json() as Promise<SearchItem[]>
})
input.addEventListener('input', async event => {
const query = (event.target as HTMLInputElement).value
const result = await searchTask.run(query)
if (result.ok) {
renderResults(result.data)
}
})If the user types quickly, previous runs are aborted and stale results are ignored.
Vue example
import { ref } from 'vue'
import { createRaceSafeTask } from 'race-safe-task'
const query = ref('')
const users = ref<User[]>([])
const error = ref<unknown>()
const searchUsers = createRaceSafeTask(async ({ signal }, query: string) => {
const response = await fetch(`/api/users?q=${query}`, { signal })
return response.json() as Promise<User[]>
})
watch(query, async value => {
const result = await searchUsers.run(value)
if (result.ok) {
users.value = result.data
error.value = undefined
return
}
if (result.status === 'error') {
error.value = result.error
}
})React example
import { useMemo, useState } from 'react'
import { createRaceSafeTask } from 'race-safe-task'
export function UserSearch() {
const [users, setUsers] = useState<User[]>([])
const searchUsers = useMemo(
() =>
createRaceSafeTask(async ({ signal }, query: string) => {
const response = await fetch(`/api/users?q=${query}`, { signal })
return response.json() as Promise<User[]>
}),
[],
)
async function onSearch(query: string) {
const result = await searchUsers.run(query)
if (result.ok) {
setUsers(result.data)
}
}
return <input onChange={event => onSearch(event.target.value)} />
}Timeout
const task = createRaceSafeTask(
async ({ signal }) => {
const response = await fetch('/api/slow-report', { signal })
return response.json()
},
{
timeout: 5000,
},
)If the task does not finish in 5 seconds, it is aborted.
Manual cancel
const task = createRaceSafeTask(async ({ signal }) => {
return fetch('/api/data', { signal }).then(r => r.json())
})
task.run()
task.cancel()State subscriptions
const unsubscribe = task.subscribe(state => {
console.log(state.status)
console.log(state.isRunning)
})
unsubscribe()State shape:
type TaskState<TResult> = {
status: 'idle' | 'running' | 'success' | 'error' | 'aborted'
runId: number
latestRunId: number
isRunning: boolean
isIdle: boolean
isSuccess: boolean
isError: boolean
isAborted: boolean
data: TResult | undefined
error: unknown
}Non-abortable work
Some async work cannot truly be canceled. That is okay.
const task = createRaceSafeTask(
async (_context, value: string, delay: number) => {
await new Promise(resolve => setTimeout(resolve, delay))
return value
},
{
cancelPrevious: false,
},
)
const first = task.run('old', 100)
const second = task.run('new', 10)
await second // success: "new"
await first // stale, ignoredEven when the old promise still resolves, race-safe-task marks it as stale.
Custom long-running handlers
Use throwIfStale() between expensive steps:
const task = createRaceSafeTask(async context => {
const user = await loadUser()
context.throwIfStale()
const permissions = await loadPermissions(user.id)
context.throwIfStale()
return { user, permissions }
})Helpers
abortableSleep(ms, signal?)
await abortableSleep(300, context.signal)Useful for tests, polling, retry delays and custom async flows.
isAbortError(error)
try {
await task.runOrThrow()
} catch (error) {
if (isAbortError(error)) return
throw error
}createAbortError(message?)
throw createAbortError('Operation canceled')Options
createRaceSafeTask(handler, {
cancelPrevious: true,
timeout: 5000,
keepPreviousData: true,
isAbortError: error => false,
onSuccess: (data, runId) => {},
onError: (error, runId) => {},
onAbort: (error, runId) => {},
onStale: (runId, latestRunId) => {},
})| Option | Default | Description |
| --- | --- | --- |
| cancelPrevious | true | Aborts the previous run when a new run starts. |
| timeout | undefined | Aborts a run after N milliseconds. |
| keepPreviousData | true | Keeps previous successful data while a new run is running. |
| isAbortError | built-in matcher | Custom matcher for abort errors. |
| onSuccess | undefined | Called when the latest run succeeds. |
| onError | undefined | Called when the latest run fails. |
| onAbort | undefined | Called when the latest run is aborted. |
| onStale | undefined | Called when an old run resolves after a newer one. |
TypeScript
Arguments and return values are inferred automatically:
const task = createRaceSafeTask(async (_context, query: string, limit: number) => {
return [{ query, limit }]
})
task.run('vue', 10) // ok
task.run('vue', '10') // TypeScript errorWhen to use
Use this package when you have async operations where only the latest result should win:
- autocomplete
- live search
- filters
- pagination
- tabs
- route-based fetches
- autosave
- file preview generation
- dashboard widgets
When not to use
You probably do not need this package when:
- every async operation must complete independently;
- you need caching, deduplication and stale-while-revalidate;
- you already use a full data-fetching library for the same concern.
FAQ
Is this a debounce library?
No. It solves a different problem: stale async results and out-of-order completion.
You can use debounce before calling task.run() if you also want fewer calls.
Does it require fetch?
No. It works with any async function. fetch is just the most common use case because it supports AbortSignal natively.
Does it work with Vue, React, Svelte and vanilla JS?
Yes. It is framework-agnostic.
Does it have runtime dependencies?
No.
License
MIT
