Caching API requests is something that's often forgotten, even though it can possibly improve performance and reduce use of resources. The implementation can be really simple, as I will show you.

For this method I'm using memory caching. That means the cache will only live in the browser tab as long as the end user does not refresh. Thus this is especially useful for single page applications, where an end user almost never uses page refreshes. If you're building a website that does make use of page refreshes, you could consider adding some persistance layer that writes your cache to localStorage or sessionStorage for example.

In the following example I've also chosen to make the ApiCache class a singleton. A pattern you don't see much in the JS/TS world, but I find it pretty convenient. A singleton makes sure you only have a single instance of your class. In this case it's useful to make sure we only have one cache where every request is saved. You can still use new ApiCache(); everywhere, it just refers to the single instance.

This caching layer provides with 3 public methods:

  • set(url, result, body?): Add an API response to the cache using the url as a caching key, result being the response of the request and optionally a body being the POST body.
  • get(url, body?): Get a cached response based on the url and optionally the POST body.
  • recordExists(url, body?): See if a cached record exists based on the url and optionally the POST body.

Easy as that!

type CacheResult = any;

export class ApiCache {
    private static instance: ApiCache;
    private cache: { url: string; body: string, result: CacheResult }[] = [];

    constructor() {
        if (!!ApiCache.instance) {
            return ApiCache.instance;
        }

        ApiCache.instance = this;

        return this;
    }

    public set = (url: string, result: any, body?: string): void => {
        if (!this.recordExists(url, body)) {
            this.cache.push({
                url,
                body,
                result
            });
        }
    }

    public get = (url: string, body?: string): CacheResult | null => {
        const cacheRecord = this.cache.find(x => {
            if (body) {
                return x.url === url && x.body === body;
            }

            return x.url === url;
        });

        if (cacheRecord) {
            return cacheRecord.result;
        }

        return null;
    }
    
    public recordExists = (url: string, body?: string): boolean => {
        return !!this.get(url, body);
    }
}

The implementation will look something like this:

export class Api {
    private apiCache = new ApiCache();

    public getResults = async () => {
        return this.getData<any>(`/url-for-results`);
    }

    private async getData<ReturnT>(url: string): Promise<ReturnT> {
        if (this.apiCache.recordExists(url)) {
            return new Promise(resolve => 
                resolve(this.apiCache.get(url))
            );
        }

        try {
            return await fetch(url)
                .then(async (res) => {
                    if (!res.ok) {
                        throw (res);
                    }

                    const text = await res.text();
                    const json = JSON.parse(text);

                    this.apiCache.set(url, json);

                    return json;
                });
        } catch (e) {
            console.error(`API call '${url}' fails with code: ${e.statusCode}. Exception: ${e.toString()}`);
        }
    }
}

Are you using another way to cache your responses? Having some comments about this implementation? Let me know!