every async function i've ever written has the same problem.
it can fail. the network blips. the service times out. three components mount at the same time and fire the same request in parallel. you know this, so you add retry. then you add a timeout. then you realize you're manually deduplicating requests. then product asks for caching.
four separate concerns. written from scratch. every. single. project.
i built Actly because i was tired of copy-pasting the same patterns.
the api is one function
import { act } from 'actly'
const result = await act('user:42', () => fetchUser(42), {
retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
timeout: { ms: 5_000 },
totalTimeout: { ms: 12_000 },
dedupe: true,
cache: { ttl: 60_000 },
})
if (result.ok) {
console.log(result.value) // your data
console.log(result.source) // 'fresh' or 'cache'
console.log(result.attempts) // how many tries it took
} else {
console.error(result.error) // never throws — always resolves
}
it never throws. failures come back as result.ok === false. you check it, you move on.
the policy ordering is intentional
this is the part i spent the most time on. internally the execution order is fixed:
totalTimeout → cache → dedupe → retry → timeout
why this order specifically?
cache sits before dedupe because a hit should skip everything below it — no deduplication needed, no retry, nothing. totalTimeout wraps the entire thing because it's a hard wall that doesn't reset — unlike timeout which gives each retry attempt a fresh clock. dedupe sits before retry so concurrent callers collapse into one before the retry loop starts. putting dedupe inside retry would mean each retry attempt spawns its own dedup group, which defeats the point.
once the ordering clicks, the behavior becomes predictable. that was the goal.
the thing that forced good design
when i added Redis/external store support, i had to define two interfaces: SyncStateStore and AsyncStateStore.
dedupe requires synchronous store access. the reason is subtle — dedupe works by reading an in-flight promise from the store, and if it's missing, writing a new one. both operations have to happen in the same synchronous frame. if get() were async, two concurrent callers could both observe a miss before either write lands, and you'd get duplicate requests anyway. the dedup would be broken silently.
so async stores are only compatible with cache. passing one to a chain that includes dedupe is a TypeScript error and a runtime error — the executor throws immediately with a clear message instead of producing silent wrong behavior.
small library. real constraint. forced a clean boundary i didn't plan for.
install
npm install actly
zero dependencies. ESM + CJS. Node 18+.
github: github.com/albytehq/actly
would love feedback — especially if you've hit edge cases with the policy ordering or have a use case the current api doesn't handle well.