DEV Community

Cover image for Cache with care
Ayelet Dahan
Ayelet Dahan

Posted on

Cache with care

A while back, I wrote a small class that caches objects. That, in itself, is possibly quite enough for a post of its own. But today I ran into a problem with my caching which I didn't foresee.

The reason I created this little class was to have a simple way to manage the freshness of the data I get and store from the server. This is a useful in-between solution when WebSocket communication with the backend is not an option.

Let's start with

The code

and break it down:

class CachedObject<T> {
    private freshness: number;
    private expiry: number;
    private object: T;

    /**
     * Object
     * @param expiry Number of milliseconds until expiration. Defaults to 10min
     */
    constructor(expiry: number = 10 * 60 * 1000) {
        this.expiry = expiry;
    }

    /**
     * Returns true if object is still fresh.
     */
    get isFresh(): boolean {
        return !!this.object && this.freshness + this.expiry > Date.now();
    }

    /**
     * Returns true if object is no longer fresh.
     */
    get isStale(): boolean {
        // convenience function
        return !this.isFresh;
    }

    /**
     * Force the instance to be stale
     */
    stale():null {
        return this.object = null;
    }

    /**
     * Gets the source object or null if the object has gone stale
     */
    get source(): T | null {
        if (this.isStale) {
            // cleanup and return null
            return this.stale();
        }

        return this.object;
    }

    /**
     * Sets the source object
     */
    set source(object: T) {
        this.freshness = Date.now();
        this.object = object;
    }
}
Enter fullscreen mode Exit fullscreen mode

If you get the gist of it, feel free to skip on ahead to the next section where I talk about the problem I ran into.

First of all, you may have noticed I'm using generics. It's a very convenient choice because this way I don't really care what object I'm given to keep in the cache.

Next, we have three private properties:

  • freshness:number - the time the cache was last set.
  • expiry:number - the number of milliseconds I want this cache to stay alive.
  • object:T - whatever we may be storing.

The constructor takes an optional expiry value, but defaults to 10 minutes if no value was provided.

Next is the little brain of the cache, a getter function checking if the contents of our cache is still "valid" or "fresh". If the object is not defined we are obviously not fresh. Also if the expiry time is up, we are not fresh. The mirror image of isFresh is isStale, a convenience function for writing more readable code. If I want to perform a task if something.isStale(), it just looks nicer than !something.isFresh().

Next, I have a little function which simply "forces" the cache to become stale.

Finally, a pair of get/set functions to set and grab the source itself. Notice that when seting the source, we also reset the freshness of our cache.

The problem I ran into

It took quite a while from the time I developed this little cache to the time I got the bug reported, but it took the first system user about 10 minutes to come across it (I just gave you a little clue there). When I was testing the system, I played around with what ever feature or bug I was working on at the time, and constantly editing the code and refreshing. So I never got to the 10 minute mark. A real user, on the other hand, may play around with the system for quite some time - especially if they are a new user.

After 10 minutes of using the cached object, it would go stale, but the user of CachedObject had to figure this out for themselves, at expiry time. Internally, I know the time is running out for this object - but I didn't have any way to warn the system that time is about to run out!

So I setup an observable. Let's see what that looks like:

class CachedObject<T> {
    private freshness: number;
    private expiry: number;
    private object: T;

    private percentToStaleSubject = new Subject<number>();
    public percentToStale$ = this.percentToStaleSubject.asObservable();

    ...
Enter fullscreen mode Exit fullscreen mode

We now have an internal Subject which knows how far are we from becoming stale. We also have an external public observable to this Subject so the external user can get these updates.

    /**
     * Force the instance to be stale
     */
    stale():null {
        this.percentToStaleSubject.next(100);
        return this.object = null;
    }

Enter fullscreen mode Exit fullscreen mode

When the object becomes stale, the percentToStale automatically jumps to 100, regardless of its previous value.

    /**
     * Returns true if object is still fresh.
     */
    get isFresh(): boolean {
        return !!this.object && this.timeToExpire < this.expiry;
    }

    private get timeToExpire(): number {
        return Date.now() - this.freshness;
    }
Enter fullscreen mode Exit fullscreen mode

I decided, while I'm at it, to add a little helper getter function timeToExpire to make the isFresh function a little more readable.

    /**
     * Gets the source object or null if the object has gone stale
     */
    get source(): T | null {
        if (this.isStale) {
            // cleanup and return null
            return this.stale();
        }

        this.percentToStaleSubject.next((this.timeToExpire / this.expiry) * 100);
        return this.object;
    }

Enter fullscreen mode Exit fullscreen mode

Finally, I want the getter function to update the "percent to stale" subject.

The user of the cache can now subscribe to get updates when the cache is nearing its stale point. The usage code looks like this:

this.cachedObject.percentToStale$
    .pipe(
        filter(pExpire => pExpire > 80), // Make sure only to refresh if nearing expiry
        throttleTime(5000) // This can fire quite a few times. Wait before firing again
    ).subscribe(() => this.goFetch());
Enter fullscreen mode Exit fullscreen mode

You may be wondering why I didn't use setTimeout or setInterval. It's a viable solution, I won't argue with you. My thinking was that I don't like timers just lying around in my system. I feel a timer should be something under your full control as a developer. On the other hand, the benefit of having the subject update only when the value is accessed makes sure we don't update the contents of the cache if nobody needs it right now.

Maybe I'll come around to changing this some day. Or maybe I'll discover there's some very trivial way to do this with rxjs.

Top comments (0)