Simple Cache Expiration for Apollo GraphQL Queries

The Problem

There are times when you want to fetch data from your server that is occasionally changed. For example, getting the number of unread messages in an application. There are a few possible solutions for keeping the data up-to-date with the backend. 1) You can poll the server every X minutes, or 2) you can use “cache-and-network” to utilize the cache first, but also check the server for updated data.

Polling

If you choose to poll for new messages, you run the risk of your application constantly calling the server for data even though the user has not recently interacted with the page. Think, checking another tab or walking away from the computer for a while. While not detrimental, this results in a lot of wasted calls and network requests.

Cache and Network

This method ensures you always have the most up-to-date information from the server, but also results in a lot of needless calls to the server. The client is constantly saying, “Ok, I’ll use the cache, but let me still call the server to check for updated data.” This query/hook may run multiple times on a single user interaction and is still to aggressive for data that only updates every few minutes.

The Solution

What we really want is the best of both worlds. We want to utilize the cache as much as possible, but also set an expiration for when to grab fresh data from the server. We can use a simple technique of utilizing Map and dates to accomplish this goal.

We’ll simply make a unique “key” for our query and record the last time we fetched that key. When we call useQuery, we pass it our “key” and the expected expiration time. If the “key” is still valid, we return to useQuery the fetchPolicy of “cache-first.” This means, use the cache if it exists and otherwise fetch from the server. If the “key” is invalid, we call useQuery with “network-only” which calls the server to refresh the data. And at the same time, we update the last fetched time of the key.

Let’s take a look at an example of the getFetchPolicyForKey() function and its implementation.

useUnreadMessages.ts

import { getFetchPolicyForKey, ONE_MINUTE } from "./hooks/useCacheWithExpiration";

export const useUnreadMessages = (): number => {
    let unreadMessages: number = 0;

    const { data, error } = useQuery(UNREAD_MESSAGES_DOC, {
        fetchPolicy: getFetchPolicyForKey("unreadMessages", ONE_MINUTE * 5),
    });

    if (isDefined(data)) {
        unreadMessages = data.unreadMessages
    }

    return unreadMessages;
}

And here’s the implementation of the cache check and when to return each fetch policy.

getFetchPolicyForKey.ts

// Record of keys and the last fetched timestamp
// These are universal for all calls and hooks
const keys = new Map<string, number>();

export const ONE_MINUTE = 1000 * 60;

/**
 * This function accepts a unique key and an expiration date. It returns "network-only" if the cache key is expired
 * or "cache-first" if the key is still valid for the given expiration time
 *
 * @param key - the unique name you want to give this expiration key
 * @param expirationMs)
 */
export const getFetchPolicyForKey = (key: string, expirationMs: number): WatchQueryFetchPolicy => {
    const lastFetchTimestamp = keys[key];
    const diffFromNow = lastFetchTimestamp ? Date.now() - lastFetchTimestamp : Number.MAX_SAFE_INTEGER;
    let fetchPolicy: WatchQueryFetchPolicy = "cache-first";

    // Is Expired?
    if (diffFromNow > expirationMs) {
        keys[key] = Date.now();
        fetchPolicy = "network-only";
    }

    return fetchPolicy;
};

Leave a Reply

Your email address will not be published. Required fields are marked *