Caching API Calls with TS Decorators
External API requests that are made repeatedly with identical parameters often yield identical responses. In such cases, caching the response data can bring substantial benefits: fewer server-side requests, better performance, and reduced data transfer.
The standard caching pattern looks like this:

Figure 1. Typical API caching workflow with optional cache invalidation
The diagram shows the basic scenario of caching responses and serving cached data on repeat calls. However, more advanced requirements can arise, such as invalidating the cache through a separate API request and notifying all subscribers with fresh data.
This exact challenge came up in my recent work. Below I'll describe two implementations we evaluated, then dive into the TS decorator approach we ultimately adopted.
Approach 1: Caching via HTTP Interceptors
Using an HttpInterceptor is the most widespread technique. The following snippet shows a typical generic implementation seen across many projects:
const CACHE_REQUEST_FLAG_HEADER_NAME = 'CACHE_REQUEST_FLAG';
const CACHE_REQUEST_ID_HEADER_NAME = 'CACHE_REQUEST_ID';
@Injectable()
export class CacheService {
setItem(key: string, item: Observable<any>): void { // ... }
getItem(key: string): Observable<any> | undefined { // ... }
invalidate(key: string): void { // ... }
}
@Injectable()
export class DataApiService {
private requestDataId = '12345';
getData(http: HttpClient) {
return this.http.get('/api/data', {
headers: {
[CACHE_REQUEST_FLAG_HEADER_NAME]: 'true',
[CACHE_REQUEST_ID_HEADER_NAME]: this.requestDataId
}
})
}
refreshData(): void {
this.cache.invalidate(this.requestDataId);
}
}
@Injectable()
export class HttpCacheInterceptor implements HttpInterceptor {
intercept(request: HttpRequest, next: HttpHandler) {
const isCached = Boolean(request.headers.get(CACHE_REQUEST_FLAG_HEADER_NAME));
const cacheId = request.headers.get(CACHE_REQUEST_ID_HEADER_NAME)
if(isCached) {
let observable = this.chache.get(cacheId);
if (observable) {
return observable;
}
// Cache request
}
return next.handle(request);
}
}
With Angular 12, you can pass extra data to interceptors using context rather than headers, like so:
const CACHE_REQUEST = new HttpContextToken<{
cached: boolean;
id: string;
}>(() => {
cached: false,
id: null,
});
@Injectable()
export class DataApiService {
private requestDataId = '12345';
getData(http: HttpClient) {
return this.http.get('/api/data', {
context: new HttpContext().set(CACHE_REQUEST , {
cached: true,
id: this.requestDataId
})
})
}
}
@Injectable()
export class CacheInterceptor implements HttpInterceptor {
intercept(request: HttpRequest, next: HttpHandler): Observable<HttpEvent<any>> {
const { cached, id } = request.context.get(CACHE_REQUEST)
if(cached && id) {
// ...
}
return next.handle(request);
}
}
I see several downsides to using interceptors for request caching:
- You need to attach a dedicated header to flag the request for caching, then check and remove it inside the interceptor. This requires a shared constant for the header name across services and the interceptor. Angular v12 eliminates this header issue via
HttpContext. - Interceptors aren't ideal for singling out the few requests that need caching among the many that don't.
- Identifying which observable to invalidate becomes tricky; one workaround is adding a header with a unique ID stored as a service property, later used as a cache key.
- A bigger concern is that returning a shared observable from the interceptor risks memory leaks. This behavior is not what developers typically expect — they normally anticipate an observable that completes upon the first emission.
- Managing concurrent requests becomes cumbersome, especially if they aren't unsubscribed or carry parameters.
Approach 2: Caching inside the API service
Handling caching directly in the service layer is an improvement, but it's not without limitations:
@Injectable()
export class ProductApiService {
productData = of(
this.http.get('...'),
defer(() => this.refreshProductSubject.pipe(
switchMap(() => this.http.get('...')))
)
).pipe(
mergeAll(),
shareReplay(1),
);
private readonly refreshProductSubject = new Subject();
refreshProducts(): void {
this.refreshProductSubject.next();
}
}
The main constraint here is passing HTTP request parameters through the caching layer.
Additionally, some developers object to a service variable that initiates HTTP calls, viewing it as a violation of single responsibility.
Approach 3: Caching logic via TS decorator
With time to think, I aimed for a solution that's both easy to use and generic enough for reuse across projects. I also wanted it to be straightforward to test.
My idea: API services contain methods that perform HTTP calls. What if each method could individually be decorated with caching behavior? This is precisely where TypeScript decorators shine.
Learn more about TS decorators in this article or consult the official documentation.
The idea is to encapsulate all caching complexity within the decorator and simply apply it to service methods, like this:
class DataApiService {
...
@HttpRequestCache<DataApiService>(function() {
return {
storage: this.cache,
refreshSubject: this.refreshSubject
};
})
getData(): Observable<any[]> {
return this.http.get('/api/data')
}
This approach turned out to be quite elegant, and I decided to use it in my project. So far, it's been problem-free. Here's how to build it.
Step 1: Create the decorator factory
interface IHttpCacheStorage {
setItem(key: string, item: Observable<any>): void;
getItem(key: string): Observable<any> | undefined;
}
interface IHttpCacheOptions {
storage: IHttpCacheStorage;
refreshSubject: Observable<unknown> | Subject<unknown>;
}
export function HttpRequestCache<T extends Record<string, any>>(
optionsHandler: (this: T) => IHttpCacheOptions
) {
// ...
}
A higher-order function that wraps the decorator is necessary to accept configuration. The optionHandler argument is a function returning an IHttpCacheOptions object. This setup is required because the decorator function or its wrapper has no access to the class instance; that access only comes via method calls or property accessors. The options handler can be either an arrow function or an anonymous function declaration.
IHttpCacheOptions fields:
storage– the repository for shared observables, typically a custom service implementingIHttpCacheStorage.refreshSubject– an Observable stream or Subject used to trigger cache invalidation.
****Step 2: Define the decorator
type HttpRequestCacheMethod = (...args: any[]) => Observable<any>;
export function HttpRequestCache<T extends Record<string, any>>(
optionsHandler: (this: T) => IHttpCacheOptions
) {
return (
target: T,
methodName: string,
descriptor: TypedPropertyDescriptor<HttpRequestCacheMethod>
): TypedPropertyDescriptor<HttpRequestCacheMethod> => {
// …
}
}
This is the method decorator function signature as per TypeScript's specification, though the logic comes later. It returns a property descriptor to be applied before class initialization.
Step 3: Implement the decorator's logic
export function HttpRequestCache<T extends Record<string, any>>(
optionsHandler: (this: T) => IHttpCacheOptions
) {
return (
target: T,
methodName: string,
descriptor: TypedPropertyDescriptor<HttpRequestCacheMethod>
): TypedPropertyDescriptor<HttpRequestCacheMethod> => {
if (!(descriptor?.value instanceof Function)) {
throw Error(`'@HttpRequestCache' can be applied only to the class method which returns Observable`);
}
const cacheKeyPrefix = `${target.constructor.name}_${methodName}`;
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]): Observable<any> {
const { storage, refreshSubject } = optionsHandler.call(this)
const key = `${cacheKeyPrefix}_${JSON.stringify(args)}`;
let observable = storage.getItem(key);
if (observable) {
return observable;
}
observable = of(
originalMethod.apply(this, args),
refreshSubject.pipe(switchMap(() => originalMethod.apply(this, args)))
).pipe(mergeAll(), shareReplay(1));
storage.setItem(key, observable);
return observable;
};
return descriptor;
}
Inside the decorator, the following happens:
- Create a cache key prefix and save a reference to the original method. Using the constructor name ensures unique cache entries even when different class instances share a method name.
- Produce the full cache key by appending the stringified function arguments to the prefix. The argument-to-string conversion follows standard memoization patterns.
- Invoke the
optionHandlerto retrieve the storage and refresh subject. As noted, calling it within the method context is essential. - If no cached observable exists, create a shared observable tied to the invalidation stream, store it, and return it.
****Step 4: Usage
@Injectable()
export class CacheService {
setItem(key: string, item: Observable<any>): void { // ... }
getItem(key: string): Observable<any> | undefined { // ... }
}
@Injectable()
export class DataApiService {
constructor(private readonly cache: CacheService) {}
private readonly refreshSubject = new Subject();
@HttpRequestCache<DataApiService>(function() {
return {
storage: this.cache,
refreshSubject: this.refreshSubject
};
})
getData(): Observable<any[]> {
return this.http.get('/api/data')
}
refreshData(): void {
this.refreshSubject.next();
}
}
Final thoughts
The first two approaches carry more inherent complexity — largely due to human factors, onboarding overhead, and code style consistency. More code generally leads to more bugs, misunderstandings, and tests to maintain.
As demonstrated, a TS decorator provides a clean, simple mechanism for adding request caching to any external API service, without extra interceptors or management services. It enables a declarative style where implementation details fade into the background. This design aligns closely with SOLID principles.
As a bonus, this pattern isn't limited to API calls — it can cache any method that returns observable data. Additionally, the implementation could be tweaked to make refreshSubject optional, allowing data to persist across the entire application lifetime.
Explore the complete solution in this Stackblitz demo.
