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 { Directive, ElementRef, HostListener, Input, OnDestroy, OnInit } from '@angular/core'; @Directive({ selector: '[appTrapFocus]',
Building a focus-trap directive in Angular
accessibility
focus-management
dom
Intermediate
9 steps
rust
use std::env; use std::time::Duration; #[derive(Debug, Clone)]
Loading typed config from environment variables in Rust
configuration
error-handling
generics
Intermediate
7 steps
typescript
import { Injectable, Scope, Inject } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express';
Request-scoped context service in NestJS
dependency-injection
request-scope
immutability
Intermediate
8 steps
rust
use std::time::Duration; use tokio::time::sleep; #[derive(Debug)]
Async retry with exponential backoff in Rust
retry
exponential-backoff
async
Intermediate
8 steps
typescript
interface PollOptions<T> { intervalMs?: number; timeoutMs?: number; signal?: AbortSignal;
A cancellable polling helper in TypeScript
polling
async-await
abortsignal
Intermediate
9 steps
rust
use std::fs; use std::io; use std::path::{Path, PathBuf};
Recursive file search by extension in Rust
recursion
filesystem
error-handling
Intermediate
8 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.