# async-cache-dedupe: a cache that never asks for the same resource twice at once

> async-cache-dedupe is a small Node library that combines a TTL cache with in-flight deduplication, so concurrent callers asking for the same key share one promise. It adds Redis storage, reference based invalidation, stale serving and custom transformers on top of a deliberately narrow core.

**mcollina/async-cache-dedupe** — Async cache with dedupe support

- Repository: https://github.com/mcollina/async-cache-dedupe
- Stars: 714 · Forks: 51
- Language: JavaScript
- License: MIT
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/mcollina-async-cache-dedupe

## One upstream call per key, shared by every caller

async-cache-dedupe is described as a cache for asynchronous fetching of resources with full deduplication, which the README glosses as the same resource being asked only once at any given time.

That is the whole idea, and it is narrower than a general caching library. Consider three concurrent requests for the same record. A plain cache with no in-flight tracking starts three upstream calls, because at the moment of the miss nothing is stored yet. This library stores the promise, so the second and third callers attach to the promise the first one created, and the upstream sees one request.

The basic shape is short. You create a cache with options, define a named function on it, and then call that function:

```js
import { createCache } from 'async-cache-dedupe'

const cache = createCache({
  ttl: 5, // seconds
  stale: 5, // number of seconds to return data after ttl has expired
  storage: { type: 'memory' },
})

cache.define('fetchSomething', async (k) => {
  console.log('query', k)
  // query 42
  // query 24

  return { k }
})
```

CommonJS require is also supported, and the cache key for an argument is computed with safe-stable-stringify, so the same arguments always produce the same key regardless of property order.

What this buys you is the deduplication, not the caching. With the default ttl of zero, the entry is removed as soon as the promise resolves, and the function is only ever deduped.

## ttl, stale and the difference between a hit and a dedupe

The ttl option is the maximum time a cache entry can live, and it defaults to zero. With zero the element is removed from the cache as soon as the promise resolves, which means the function acts as a pure deduplicator.

The stale option is the more interesting one. It is the time after which the value is still served from the cache once the ttl has expired, and it can be a number of seconds or a function that receives the data and returns the stale value. The result is the familiar stale while revalidate pattern: after the entry goes stale, the first caller is given the old value and a refresh is triggered behind it, while later callers within the stale window are served the old value immediately.

Both options exist at two levels. They can be set once for the whole cache, and they can be overridden per defined function, where the per function ttl can itself be a function of the result of the original function:

```js
  const cache = createCache()

  cache.define('fetchAccessToken', {
    ttl: (result) => result.expiresInSeconds
  }, async () => {
    
    const response = await fetch("https://example.com/token");
    const result = await response.json();
    // => { "token": "abc", "expiresInSeconds": 60 }
    
    return result;
  })
```

That pattern is the one to copy for credential style data, where the lifetime of the cached value is a property of the value itself. The same trick works for stale, with a function reading the result to decide how long the old value may still be served.

Four event hooks report what the cache is doing: onDedupe, onError, onHit and onMiss, each callable both on the cache and on an individual defined function. They are the intended way to build metrics, and they are the only observability the library offers.

## Memory per resolver, or Redis when the cache must be shared

Storage is configurable and there are two types. The default is memory, whose options include a size, the maximum number of items to store in the cache per resolver, defaulting to 1024, an invalidation toggle that is disabled by default, and a log option taking a pino compatible logger, also disabled by default. The size is per resolver, which is worth noticing: it is a bound on each defined function's own cache rather than on the whole cache object.

```js
      createCache({ storage: { type: 'memory', options: { size: 2048 } } })
```

The redis type is the reason many teams reach for this library. Its client option is mandatory and should be an ioredis client or something compatible, invalidation is again available, and invalidation carries a referencesTTL setting, the lifetime in seconds of the reference records, which the README says should be set at the maximum of all the caches ttl.

```js
      createCache({ storage: { type: 'redis', options: { client: new Redis(), invalidation: { referencesTTL: 60 } } } })
```

Storage can also be chosen per defined function, which the README describes as fine-tuning, so a hot lookup can sit in memory while a slower, shared lookup lives in Redis.

The practical difference between the two is where the deduplication happens. With memory storage, two Node processes each keep their own in-flight map, so a burst of traffic spread across four workers still produces four upstream calls. With Redis, the shared storage is what makes the collapsing visible across processes, and that is the case the library is really built for.

## Invalidation runs on references you attach yourself

Cache invalidation in this library is a two part design. The producer side is a references option on a defined function, a sync or async function that receives the args, the key and the result of the call and must return an array of strings or a falsy value. The consumer side is the invalidateAll method, which performs invalidation over the whole storage, or over the default storage when a storage is not specified.

The example is short enough to read as a specification:

```js
    const cache = createCache({ ttl: 60 })

    cache.define('fetchUser', {
      references: (args, key, result) => result ? [`user~${result.id}`] : null
    }, 
    (id) => database.find({ table: 'users', where: { id }}))

    await cache.fetchUser(1)
```

The defined function tags its result with a reference string built from the user id, and later code can invalidate everything tagged with that reference when the record changes. The referencesTTL option on the redis storage controls how long those reference records live, which is why the README says it should be the maximum of all the caches ttl.

For finer control there is cache.clear, which takes an optional name and an optional arg. Given a name it clears all entries cached by the function defined under that name, and given an arg as well it clears only the entries cached for that name and argument.

The limitation is inherent in the design. References are strings that you construct, and nothing in the library checks that two functions agree on their naming, so a typo in one of them produces an invalidation that silently does nothing.

## Serialisation is your problem, and transformers hand it to you

Caching a value means writing it to a storage that may not hold the same type you have in memory. The transformer option is how you take charge of that. It must be an object with two methods, serialize, which receives the result of the original function and returns a serialisable object, and deserialize, which receives the serialised object and returns the original result. The default is undefined, meaning the default transformer is used.

The documented example uses superjson, which handles the awkward cases like dates and undefined that plain JSON does not:

```js
    import superjson from 'superjson';

    const cache = createCache({
      transformer: {
        serialize: (result) => superjson.serialize(result),
        deserialize: (serialized) => superjson.deserialize(serialized),
      }
    })
```

A transformer can also be set per defined function, in the same way storage can, so a function whose value is plain JSON can use the default while another one needs richer encoding.

There is a related per function option called serialize, which converts the given argument into a serialisable object or string. That is about the key side rather than the value side, and it exists for cases where the default key computation is not what you want.

The trade-off is worth stating plainly. A transformer is code you write and must keep in step with your data, and if the shape of a cached value changes, an old entry written with the previous transformer will fail to deserialize. The library does not version entries by transformer, so invalidating on a shape change is your responsibility.

## The test and packaging story: borp, tstyche and a browser runner

The package manifest describes a project that is run seriously, which matters for something sitting in the request path. The test script runs eslint, then borp with coverage and a check coverage flag and expose-gc over the test files, then tstyche, which is the type level test runner:

```json
  "scripts": {
    "test": "eslint && borp --coverage --check-coverage --expose-gc test/*test.js && tstyche",
    "test:types": "tstyche",
    "test:browser": "node test/browser/helpers/runner-browser.mjs",
    "lint:fix": "eslint --fix"
  },
```

There is a separate browser test runner script, and the development dependencies explain why: playwright, browserify, esbuild, rollup and webpack are all present, along with a wrangler configuration, which together suggest the library is bundled for the browser and tested there as well as in Node. The runtime dependencies are small, with mnemonist and safe-stable-stringify named, the latter of which is the same package used for key computation.

The published surface is index.js with type definitions in types/index.d.ts, and the examples directory holds a basic module and a redis example. The licence is MIT, the author is Matteo Collina, and the version in the manifest is 3.4.0.

The release pattern deserves a note. The recent tags are v3.4.0 on 2025-12-14, then v3.3.0 and v3.2.0 on the same day in late October 2025, while the last push to the repository was on 2026-10-19. So the code is moving faster than the published versions, and if you pin a version from npm you are pinning code that is up to a year older than the branch head.

## Conclusion

Adopt async-cache-dedupe when your service makes concurrent calls for the same key and you want the calls collapsed into one upstream request, especially if you also need Redis backed storage across processes or reference based invalidation. Do not adopt it for a purely local, single-process cache, since the deduplication logic itself is a few lines and the library only earns its place when you use the storage or invalidation features. Verify first that the ttl and stale semantics fit your refresh policy, that a Redis client is available if you choose that storage, and that the version you install matches the 3.x line, since the newest tagged release is v3.4.0 from 2025-12-14 while the repository itself was pushed to on 2026-10-19.

## FAQ

### What does async-cache-dedupe do?

It is a cache for asynchronous fetching of resources with full deduplication, meaning the same resource is only asked once at any given time. Concurrent calls for the same key share one promise, so the upstream sees a single request rather than one per caller.

### How do I install async-cache-dedupe?

Install it with npm using npm i async-cache-dedupe. The package is published with index.js as the main entry and type definitions in types/index.d.ts, and CommonJS require is supported alongside ES module imports.

### What is the difference between ttl and stale in async-cache-dedupe?

The ttl is the maximum time an entry can live and defaults to zero, in which case the entry is removed as soon as the promise resolves. The stale option is the extra time after the ttl expires during which the old value is still served, and it can be a number of seconds or a function of the result.

### How do I invalidate entries in async-cache-dedupe?

Attach references to a defined function with a function receiving the args, key and result and returning an array of strings, then call cache.invalidateAll with those reference strings. The redis storage also has an invalidation.referencesTTL option, which the README says should be set to the maximum of all the caches ttl, and cache.clear can clear by function name and argument.

### Can async-cache-dedupe use Redis?

Yes. The storage type redis requires a client option, which should be an ioredis client or compatible, and it supports invalidation, a referencesTTL for the lifetime of reference records and a pino compatible logger. The default storage type is memory, with a size of 1024 items per resolver.

## Sources

- [Issues](https://github.com/mcollina/async-cache-dedupe/issues)
- [License: MIT](https://github.com/mcollina/async-cache-dedupe/blob/main/LICENSE)
- [mcollina/async-cache-dedupe on GitHub](https://github.com/mcollina/async-cache-dedupe)
- [README](https://github.com/mcollina/async-cache-dedupe/blob/main/README.md)
- [Releases](https://github.com/mcollina/async-cache-dedupe/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/mcollina-async-cache-dedupe
