typescript
39 lines · 8 steps
Memoizing async functions with TTL in TypeScript
A generic wrapper caches in-flight promises by argument key and expires them after a configurable time-to-live.
Explained by
highlit
1type AsyncFn<A extends unknown[], R> = (...args: A) => Promise<R>;
2
3interface MemoizeOptions<A extends unknown[]> {
4 keyFn?: (...args: A) => string;
5 ttlMs?: number;
6}
7
8interface CacheEntry<R> {
9 value: Promise<R>;
10 expiresAt: number;
11}
12
13export function memoizeAsync<A extends unknown[], R>(
14 fn: AsyncFn<A, R>,
15 options: MemoizeOptions<A> = {},
16): AsyncFn<A, R> {
17 const { keyFn = (...args) => JSON.stringify(args), ttlMs = Infinity } = options;
18 const cache = new Map<string, CacheEntry<R>>();
19
20 return async (...args: A): Promise<R> => {
21 const key = keyFn(...args);
22 const now = Date.now();
23 const cached = cache.get(key);
24
25 if (cached && cached.expiresAt > now) {
26 return cached.value;
27 }
28
29 const value = fn(...args);
30 cache.set(key, { value, expiresAt: now + ttlMs });
31
32 try {
33 return await value;
34 } catch (err) {
35 cache.delete(key);
36 throw err;
37 }
38 };
39}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Caching the promise itself, not the resolved value, deduplicates concurrent calls before any of them finish.
- 2Deleting the entry when the promise rejects prevents a transient failure from being cached forever.
- 3Generic parameters over the argument tuple and return type keep the wrapper fully type-safe for any async function.
Related explainers
typescript
import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core'; interface Preferences { theme: 'light' | 'dark';
A signal-based preferences store in Angular
signals
state-management
persistence
Intermediate
7 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
php
<?php namespace App\Services;
Building a cached daily leaderboard in Laravel
caching
aggregation
eager-loading
Intermediate
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/memoizing-async-functions-with-ttl-in-typescript-explained-typescript-6c38/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.