Skip to content
Agentmart
Submit a tool

async-safety

Prevents silent async bugs: effect races, stale results, lost promises, leaked listeners and timers, AbortSignal gaps.

Skill by zakariaf

IndexedMIT★ 11
Write a review

Install

Works with Claude Code, Claude apps, Codex, Cursor, Gemini CLI, GitHub Copilot, VS Code, OpenCode, Goose, Windsurf. Read the repository before you give any tool access to your data.

npx agentmarket install skill/zakariaf-async-safety

Asks which of your tools to add it to (Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI, Claude Desktop) and shows the change first. See https://agentmart.dev/cli.

Get the weekly diff

The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.

We use your email only for this newsletter. Privacy

About

Prevents silent async bugs: effect races, stale results, lost promises, leaked listeners and timers, AbortSignal gaps. Use when writing useEffect, setTimeout, listeners or cancellation. Not for query caching (networking-and-server-state).

Use cases: backend, writing

No reviews yet. Installed it? Reviews come from verified installers only. How reviews work

Permissions

What this skill can do and what it asks for, from its SKILL.md and code. Checked Oct 26, 2026.

Tools
No tools found
Secrets
None asked for
Install scripts
None found
Runs as
Node.js package

Network hosts it talks to 1

  • codeload.github.com · found in install command

Files it touches

  • ~/.claude/skills/async-safety

What's inside

Skill name
async-safety
Description
Prevents silent async bugs: effect races, stale results, lost promises, leaked listeners and timers, AbortSignal gaps. Use when writing useEffect, setTimeout, listeners or cancellation. Not for query caching (networking-and-server-state).
License
MIT
Instructions
212 lines in SKILL.md · view on GitHub

Before installing

  • Ships executable scripts
  • Instructions call the network

Files (33)

  • SKILL.md
  • shared.json · assets/
  • task-details-port.ts · examples/
  • use-task-details-model.test.ts · examples/
  • use-task-details-model.ts · examples/
  • use-task-search-model.test.ts · examples/
  • use-task-search-model.ts · examples/
  • watch-sync-status.test.ts · examples/
  • watch-sync-status.ts · examples/
  • cancellation-and-deadlines.md · references/
  • checker-rules.md · references/
  • effects-and-races.md · references/
  • listeners-and-timers.md · references/
  • promises-and-handlers.md · references/
  • silent-failure-catalogue.md · references/
  • test-recipes.md · references/
  • check-async-safety.mjs · scripts/
  • check-lib.mjs · scripts/
  • async-scan.mjs · scripts/lib/
  • source-scan.mjs · scripts/lib/
  • selftest.mjs · scripts/
  • latest-request.test.ts · templates/src/core/async/
  • latest-request.ts · templates/src/core/async/
  • delay-or-abort.test.ts · templates/src/services/async/
  • delay-or-abort.ts · templates/src/services/async/
  • with-timeout.test.ts · templates/src/services/async/
  • with-timeout.ts · templates/src/services/async/
  • package.json · tests/bases/app/
  • _layout.tsx · tests/bases/app/src/app/
  • tsconfig.json · tests/bases/app/
  • CASES.json · tests/fixtures/check-async-safety/
  • SUITE.json · tests/fixtures/check-async-safety/
  • EXPECT.txt · tests/fixtures/check-async-safety/good/

SKILL.md (first lines)

# Async safety

No async failure in an Expo app is silent and no async result lands where it no longer belongs: every
promise has an owner that sees its rejection, every effect cancels the work it started, overlapping
calls resolve latest-wins, cancellation is read from the signal (with only the abort APIs a phone
really has), every subscription and timer is released by whoever created it, and every wait has a
deadline. A zero-dependency checker proves the parts ESLint and `tsc` cannot see, on iOS and Android
code alike; race tests prove the rest.

## Rules that must hold

The inline handler forms (no floating promise, no `async` handler, block-bodied handlers) are
typescript-and-lint-rules rule 10 and its ESLint config; these rules close everything after them.
Server data is read with `useQuery` (networking-and-server-state): no effect, no
`createLatestRequest`, because query keys already give latest-wins and TanStack passes the signal.
Rules 3 and 4 are for async work that is not server state: SQLite repositories, native SDKs, OS
reads, device APIs.

1. **No promise goes into a slot that ignores it.** A promise-returning function (an `async` function,
   one typed `Promise<...>`, an `*Async` call, `Linking.openURL`) never goes by reference into an `on*`
   prop, an `on*` model field or a listener: wrap it, `onPress={() => { retry().catch(reportError); }}`.
   Why: React Native's Strict API types `Pressable` `onPress`, `TextInput` `onSubmitEditing` and
   `Keyboard` listeners as returning `unknown`, and `RefreshControl` `onRefresh` accepts a Promise it
   never awaits, so no linter sees `onPress={retry}` (verified) and the rejection vanishes in
   release. (`async-by-reference`)
2. **Callback types return `void`.** Every `on*` prop, `on*` model field and `handle*` member is typed
   `(...) => void`, never `Promise<...>` or `unknown`; the model owns the promise and exposes a void
   intent plus its state (`isSaving`). Why: with `void`, `no-misused-promises` reports every async
   function passed in, so the type does the review at each call site. (`void-callback-type`)
3. **An effect owns the async work it starts.** One `AbortController` per run (or an `isActive` flag
   for work that takes no signal), aborted by the cleanup; every write after an await checks
   `signal.aborted` first; a subscription that arrives asynchronously (`watchPositionAsync`) is removed
   on arrival when the cleanup already ran; state remembers which request it answers. Why: answers
   arrive out of order, and a response for the previous id or a left screen otherwise overwrites,
   navigates or leaks. (`effect-stale-result`)
4. **Overlapping calls of one read resolve latest-wins; writes never run twice.** Search, filters
   and refresh over local or SDK data, and the first read of an OS value, use one
   `createLatestRequest()` per action, so a newer call (or a change event) aborts the older one; a
   write is never aborted for a newer one and is disabled while in flight. Why: the last answer to arrive is not the last one asked for, and an
   aborted write may still have landed. (race tests, `templates/src/core/async/latest-request.ts`)
5. **Cancellation is read from the signal and is never a failure.** After a rejection ask
   `signal.aborted`, never `error.name === 'AbortError'` or `instanceof DOMException`; a cancelled call
   shows nothing and records nothing; use only `abort()`, `aborted` and hand-removed listeners. Why:
   on a device expo/fetch rejects most aborted requests with a `FetchError`, and React Native's
   AbortSignal polyfill has no `throwIfAborted()` or `AbortSignal.abort()`, drops the reason passed to
   `abort(reason)` and ignores `{ signal }` listener options, while Jest on Node has them all.
   (`abort-signal`)
6. **Every subscription handle is kept and released by its creator.** The value of `addEventListener`,
   `addListener`, `add*Listener`, `subscribe` and `watch*()` is stored and removed in the matching
   cleanup, a `useSyncExternalStore` subscribe's return or a `stop()`; never `removeAllListeners` on a
   shared emitter; only a composition-root listener that lives as long as the app may say
   `// app-lifetime: <why>` instead. Why: a leaked listener fires after unmount, Fast Refresh stacks
   copies, and a leaked Android back handler swallows every later back press. (`subscription-released`)
7. **Every timer id is kept and cleared; pauses are abortable.** `setTimeout`, `setInterval`,
   `requestAnimationFrame` and `requestIdleCallback` ids go to their clear function in the cleanup,
   `finally` or `stop()`; a pause is `await delayOrAbort(ms, signal)`, never
   `new Promise((resolve) => setTimeout(resolve, ms))`. Why: a timer outlives its owner and acts on
   state nobody shows; an uncancellable sleep keeps a retry loop alive after its screen is gone.
   (`timer-cleared`)
8. **Screen work runs only while the screen is focused and the app is active.** Polling, countdowns,
   sensors and location in a screen run in `useFocusEffect` and pause while `useIsAppActive()` is
   false. Server polling is `useQuery({ refetchInterval, subscribed: useIsFocused() })`, never a
   hand-rolled interval. Why: covered stack screens and inactive tabs stay mounted with their effects
   (and query observers) running, and Android keeps JS running in the background where iOS suspends
   it. (`focus-scoped-work`)
9. **Every wait that can hang has a deadline that cancels what it abandons.** `withTimeout(task,
   afterMs, signal)` returns `timed-out` or `cancelled` as Result failures and aborts the task's
   signal; retries are bounded, back off through `delayOrAbort` and retry only retryable kinds;
   server-state retries stay with TanStack Query. Why: a hung request keeps the spinner forever, and a
   timed-out request left running writes later. (race tests, `templates/src/services/async/with-timeout.ts`)
10. **Every async unit has a race test.** Answers settled out of order, unmount in the middle, a
    cancelled call that records nothing, and nothing left behind (`jest.getTimerCount()` 0, a fake's
    listener count back to 0). Why: every test that resolves promises in start order passes the race
    bugs this skill exists for.

## Workflow

1. **Classify the change**: a handler or callback type (rules 1-2), an effect (3), overlapping calls
   or an OS mirror (4), cancellation (5), a listener or timer (6-7), screen-scoped work (8), a wait or
   retry (9). Read [references/silent-failure-catalogue.md](references/silent-failure-catalogue.md)
   once to see the whole list.
2. **Add the async toolkit when first needed**, each with its test, at the path its first line names:
   `templates/src/core/async/latest-request.ts`, `templates/src/services/async/delay-or-abort.ts`,
   `templates/src/services/async/with-timeout.ts` (it imports `src/core/result.ts`, which
   error-handling-typed-results creates). Copy them as they are; they pass tsc, ESLint and Jest in the
   reference app. An existing `with-timeout.ts` that takes a promise (`withTimeout(sdk.fetch(), 8000)`)
   is replaced: rewrite its call sites to the task form first
   ([references/cancellation-and-deadlines.md](references/cancellation-and-deadlines.md), section 6).
3. **Handlers and callback types**: read
   [references/promises-and-handlers.md](references/promises-and-handlers.md). Type every `on*` member
   `=> void`; let the model own the promise and expose a void intent; wrap any promise-returning
   reference.
4. **Effects** (only for work that is not server state; a server read is a `useQuery`, handed to
   networking-and-server-state): read [references/effects-and-races.md](references/effects-and-races.md)
   sections 1-6 and imitate [examples/use-task-details-model.ts](examples/use-task-details-model.ts)
   (a SQLite read: one controller per run, a request key beside the answer, cancellation not recorded).
5. **Overlapping calls and OS mirrors** over local, SDK or OS sources (server search and filters are
   query keys): sections 7-11 of the same reference; imitate
   [examples/use-task-search-model.ts](examples/use-task-search-model.ts) (latest-wins, debounce,
   deadline) and [examples/watch-sync-status.ts](examples/watch-sync-status.ts) (subscribe first, an
   event cancels the first read).
6. **Cancellation, deadlines, retries, ports**: read
   [references/cancellation-and-deadlines.md](references/cancellation-and-deadlines.md). Give every
   abandonable port member a `signal` parameter
   ([examples/task-details-port.ts](examples/task-details-port.ts)) and decide cancellation by
   `signal.aborted` in the adapter.
7. **Listeners and timers**: read [references/listeners-and-timers.md](references/listeners-and-timers.md)
   for the release call of each API, the New Architecture timer facts and the background behaviour of
   iOS and Android. Use `useFocusEffect` for screen work; mark a composition-root listener that lives
   as long as the app with `// app-lifetime: <why>`.
8. **Tests**: write the race tests first, from
   [references/test-recipes.md](references/test-recipes.md) (controlled promises, out-of-order,
   unmount, fake timers, nothing left behind); see each fail by deleting the guard it protects.
9. **Run the checks** from the repo root: `npm run typecheck`, `npm run lint` (the lint rules of
   typescript-and-lint-rules carry rule 10 there), `npx jest <changed tests> --ci`, then
   `node ${CLAUDE_SKILL_DIR}/scripts/check-async-safety.mjs .`. Fix every `FAIL` line (it names the
   file, the rule and the fix; [references/checker-rules.md](references/checker-rules.md) explains each
   rule and its limits) and rerun until `RESULT: PASS`.
10. **On a device**, for a change in rows marked D of the catalogue (abort code, backgrounding,

From zakariaf/ReactNative-Skills, MIT.

More from zakariaf/ReactNative-Skills

Signals

Stars
11
Forks
2
Open issues
0
Last commit (this folder)
Oct 17, 2026 (11 days ago)

From GitHub (fetched Oct 24, 2026).

Details

License
MIT
First seen
Oct 24, 2026
Last checked
Oct 24, 2026

More from zakariaf

Cite this page

async-safety skill. Agent Mart. https://agentmart.dev/skills/zakariaf-async-safety (last updated 2026-10-24)