@firekid/once
v1.0.3
Published
Deduplicate async function calls. The modern, memory-safe replacement for inflight.
Maintainers
Readme
@firekid/once
The modern, memory-safe replacement for inflight.
Deduplicate async function calls. Zero dependencies. Full TypeScript support.
The Problem
inflight has a memory leak. It never cleans up its internal map after callbacks run, which causes Node.js heap crashes in long-running processes. It also uses callbacks instead of Promises, has no TypeScript support, and only runs in Node.
@firekid/once fixes all of it.
Installation
npm install @firekid/once
yarn add @firekid/once
pnpm add @firekid/onceQuick Start
import { once } from '@firekid/once'
const getUser = once(async (id: string) => {
return await db.query('SELECT * FROM users WHERE id = ?', [id])
})
const [a, b, c] = await Promise.all([
getUser('123'),
getUser('123'),
getUser('123'),
])
// Only 1 DB query fired. All 3 get the same result.Core Behavior
When the same async function is called multiple times with the same arguments before the first call resolves, once deduplicates — only one invocation runs and all callers receive the same result.
After the promise settles (resolve or reject), the entry is automatically removed from the internal map. No memory leak. Ever.
Errors are propagated to all waiting callers. After a rejection, the key is cleared so the next call retries cleanly.
API
once(fn, options?)
Wraps an async function with deduplication.
once(fn, options?)type OnceOptions<TArgs, TResult = any> = {
key?: string | ((...args: TArgs) => string)
ttl?: number
maxKeys?: number
onDeduplicated?: (key: string) => void
staleWhileRevalidate?: boolean
shouldCache?: (value: TResult) => boolean
store?: Map<string, OnceCacheEntry<TResult>>
}Returns an OnceInstance, callable and with .clear(), .size(), .has(), .abort(), .refresh(), and .stats() methods.
createOnce(defaults?)
Creates a factory with default options applied to every wrapped function.
const wrap = createOnce({ ttl: 5000 })
const getUser = wrap(async (id: string) => fetchUser(id))
const getPosts = wrap(async (userId: string) => fetchPosts(userId))Options
key
By default the key is generated from the function arguments via JSON.stringify. You can override this with a static string or a function.
const fn = once(fetchConfig, { key: 'config' })
const fn = once(fetchUser, {
key: (id, _role) => `user:${id}`
})ttl
Keep the result in the dedup window for a number of milliseconds after the promise resolves. All calls within that window return the cached result without re-executing.
const fn = once(fetchConfig, { ttl: 5000 })
const first = await fn()
await sleep(3000)
const second = await fn() // returned from dedup window
await sleep(3000)
const third = await fn() // ttl expired, executes againmaxKeys
Limit the number of in-flight keys tracked simultaneously. When the limit is reached, additional calls bypass deduplication and execute directly.
const fn = once(fetchUser, { maxKeys: 100 })onDeduplicated
Called every time a call is deduplicated instead of executed. Useful for logging and metrics.
const fn = once(fetchUser, {
onDeduplicated: (key) => {
metrics.increment('dedup.hit', { key })
}
})staleWhileRevalidate
Requires ttl. Once the cached value expires, the next call still gets the stale value immediately while a fresh call runs in the background. Concurrent stale reads never trigger more than one background refresh.
const fn = once(fetchConfig, { ttl: 5000, staleWhileRevalidate: true })
const first = await fn()
await sleep(6000)
const second = await fn() // stale value, returned instantly, refresh started in backgroundshouldCache
Predicate checked against the resolved value. Return false to skip caching that result, so the next call executes fresh.
const fn = once(fetchUser, {
shouldCache: (user) => user !== null
})store
Provide your own Map instance for the cache instead of the built-in one, for example to share one map across multiple wrapped functions or swap in a custom Map subclass.
const shared = new Map()
const fn = once(fetchUser, { store: shared })Instance Methods
const fn = once(fetchUser, { ttl: 5000 })
fn.clear() // clear all cached and in-flight entries
fn.clear('123') // clear a specific key
fn.size() // number of currently tracked keys
fn.has('123') // whether a key is currently tracked
fn.abort() // stop tracking every in-flight call
fn.abort('123') // stop tracking a specific key, next call executes fresh
fn.refresh('123') // bypass the cache for this call, updates it with the fresh result
fn.stats() // { hits, misses, dedup } countersError Handling
Errors are shared across all concurrent callers and the key is immediately cleared after rejection, allowing clean retries.
const fn = once(async (id: string) => {
return await riskyOperation(id)
})
const results = await Promise.allSettled([fn('x'), fn('x'), fn('x')])
const result = await fn('x') // retries cleanly after rejectionReal World Examples
API route — deduplicate DB calls
import { once } from '@firekid/once'
const getUser = once(async (id: string) => {
return await db.users.findUnique({ where: { id } })
}, { ttl: 2000 })
app.get('/users/:id', async (req, res) => {
const user = await getUser(req.params.id)
res.json(user)
})NestJS provider
import { Injectable } from '@nestjs/common'
import { once } from '@firekid/once'
@Injectable()
export class UserService {
private getUser = once(async (id: string) => {
return this.userRepo.findOne(id)
}, { ttl: 3000 })
find(id: string) {
return this.getUser(id)
}
}Config loading — load once, reuse everywhere
import { once } from '@firekid/once'
const loadConfig = once(async () => {
return await fetch('/api/config').then(r => r.json())
})
const config = await loadConfig()Replace inflight directly
import { once } from '@firekid/once'
const fn = once(async () => {
return await myAsyncOperation()
}, { key: 'my-key' })
await fn()TypeScript
import { once, OnceOptions, OnceInstance } from '@firekid/once'
type User = { id: string; name: string }
const getUser: OnceInstance<[string], User> = once(
async (id: string): Promise<User> => fetchUser(id),
{ ttl: 5000 }
)
const user = await getUser('123')
user.nameResponse Shape
type OnceInstance<TArgs, TResult> = {
(...args: TArgs): Promise<TResult>
clear: (key?: string) => void
size: () => number
has: (key: string) => boolean
abort: (key?: string) => void
refresh: (...args: TArgs) => Promise<TResult>
stats: () => { hits: number; misses: number; dedup: number }
}Environment Support
Works anywhere a JavaScript Promise is available.
- Node.js 18 and above
- Cloudflare Workers
- Vercel Edge Functions
- Deno
- Bun
- Browser
Exports both ESM (import) and CommonJS (require).
License
MIT
Built by Firekid♥️ — All rights reserved
