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

Walkthrough

Space play step click any line
Three takeaways
  1. 1Caching the promise itself, not the resolved value, deduplicates concurrent calls before any of them finish.
  2. 2Deleting the entry when the promise rejects prevents a transient failure from being cached forever.
  3. 3Generic parameters over the argument tuple and return type keep the wrapper fully type-safe for any async function.

Related explainers

typescript
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, timer, throwError } from 'rxjs';
import { switchMap, takeWhile, filter, take, catchError } from 'rxjs/operators';

Polling a job until it finishes in Angular

rxjs polling observables
Intermediate 7 steps
go
package handler
 
type flightResult struct {
	status int

Deduping in-flight requests in Gin

middleware concurrency deduplication
Advanced 9 steps
typescript
type Flatten = Record<string, unknown>;
 
function isPlainObject(value: unknown): value is Record<string, unknown> {
  return (

Flattening nested objects into dotted keys

recursion reduce type-guards
Intermediate 7 steps
typescript
import { Injectable, signal, computed, effect, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
 
export type Theme = 'light' | 'dark';

A signal-based theme service in Angular

signals reactivity dependency-injection
Intermediate 7 steps
javascript
import { useCallback, useRef, useState } from 'react';
 
export function ColorPicker({ initialColor = '#3b82f6', onCommit }) {
  const [committed, setCommitted] = useState(initialColor);

A validated color picker in React

uncontrolled-inputs refs validation
Intermediate 7 steps
typescript
import { Injectable } from '@angular/core';
import { HttpClient, HttpEventType, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map, distinctUntilChanged, scan } from 'rxjs/operators';

Tracking upload progress in Angular

rxjs http-events state-reduction
Intermediate 8 steps

Share this explainer

Here's the card — post it anywhere.

Memoizing async functions with TTL in TypeScript — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code