async-safety
Prevents silent async bugs: effect races, stale results, lost promises, leaked listeners and timers, AbortSignal gaps.
Skill by zakariaf
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.
About
Use cases: backend, writing
Reviews
Write a reviewNo 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
- 0.606score
debug-menu-and-test-hooks
zakariaf
Gates test-only code behind one literal TEST_ONLY switch: debug menu, debug link, simulated clock and offline.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
accessibility-as-code
zakariaf
Enforces a11y labels, 44pt targets, 200% text, VoiceOver, TalkBack.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
adaptive-layout
zakariaf
Lays screens out by window, not device: breakpoints, master-detail panes, readable widths, grids.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
admob-ads-and-consent
zakariaf
Builds AdMob ads behind a port - banners, interstitials, rewarded, UMP consent then ATT, pacing, ADS_MODE test IDs.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
analytics-and-consent
zakariaf
Gates analytics on opt-in consent: no SDK before a yes, typed events without personal data, ATT.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
android-release-play
zakariaf
Ships Android App Bundles to Google Play: upload key, Play App Signing, AAB gate, versionCode, tracks, rollout.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
architecture-and-boundaries
zakariaf
Places app code in the src/ layout and checks import boundaries: one-way zones, pure core, ports with fakes, one composition root.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
audio-and-haptics-feedback
zakariaf
Builds SFX and haptics behind ports.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
auth-and-secure-storage
zakariaf
Builds sign-in - tokens in expo-secure-store, shared refresh, protected routes, OAuth PKCE, Apple sign-in.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
background-tasks
zakariaf
Builds expo-background-task jobs: idempotent, budgeted runs.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
ci-pipeline-and-gates
zakariaf
Builds GitHub Actions CI for Expo apps - verify on every PR, SHA-pinned actions, pinned runners and Node, device lanes, manual EAS releases.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
canvas-gestures-and-hit-testing
zakariaf
Turns canvas taps, swipes, drags and aim into typed intents: one gesture module, hit-testing on the drawn layout.
SkillAFreshness: A★ 11Claude Code +9
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
- Repository
- github.com/zakariaf/ReactNative-Skills
- First seen
- Oct 24, 2026
- Last checked
- Oct 24, 2026
- Source
- GitHubgithub-graphql
More from zakariaf
- 0.606score
admob-ads-and-consent
zakariaf
Builds AdMob ads behind a port - banners, interstitials, rewarded, UMP consent then ATT, pacing, ADS_MODE test IDs.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
accessibility-as-code
zakariaf
Enforces a11y labels, 44pt targets, 200% text, VoiceOver, TalkBack.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
canvas-gestures-and-hit-testing
zakariaf
Turns canvas taps, swipes, drags and aim into typed intents: one gesture module, hit-testing on the drawn layout.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
adaptive-layout
zakariaf
Lays screens out by window, not device: breakpoints, master-detail panes, readable widths, grids.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
debug-menu-and-test-hooks
zakariaf
Gates test-only code behind one literal TEST_ONLY switch: debug menu, debug link, simulated clock and offline.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
analytics-and-consent
zakariaf
Gates analytics on opt-in consent: no SDK before a yes, typed events without personal data, ATT.
SkillAFreshness: A★ 11Claude Code +9
Cite this page
async-safety skill. Agent Mart. https://agentmart.dev/skills/zakariaf-async-safety (last updated 2026-10-24)