npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

race-safe-task

v0.1.0

Published

Tiny TypeScript utility to prevent stale async results and race conditions in UI code.

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

Why 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, ignored

Even 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 error

When 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