1. Journal Stack Home

Introduction

Synopsis

Caching in the browser has been a thing that's existed for a long time. A simple key-value store (cache) for storing responses with requests as keys and it was packaged in the Cache API. We also have the CacheStorage API that allows us to create and manage multiple caches. That was all that caching responses in the browser involved, until now.

RemixCache is a wrapper around the Cache API that allows us to store and retrieve responses from the cache with more refined control. It supercharges the Cache API by adding some additional features to the normal caching process of storing and retrieving responses.

By default, items live in the cache forever with no limit, you are also allowed to store as much as you like which is fine, till you want to finetune your cache. RemixCache is a framework-agnostic wrapper (it works outside Remix too!) that utilises the browser cache with expiration, and size limits. That's not all, it also allows integration with cachified, a popular library for caching, shipping with all these right out the box!

Installation

To get started, run:

npm install `@remix-pwa/cache`

RemixCache isn't directly available to be able to create and mutate, but no worries, cause @remix-pwa/cache also ships with a RemixCacheStorage which is available in the global scope (worker & main thread). RemixCacheStorage (alias Storage) is a wrapper around the CacheStorage API that allows us to create and manage multiple caches of RemixCache instances.

Basic Usage

Using @remix-pwa/cache is no different to using the Cache API. The only difference is to make sure you have opened the cache somewhere at the top of your service worker before utilising it anywhere else to ensure it is always wrapped. It's no different from how it used to be in Remix PWA v2.x.x.

import { Storage } from "@remix-pwa/cache";

// Here we open a cache called 'my-cache'
// `cache` is an instance of `RemixCache`
const cache = await Storage.open("my-cache", {
  // ...options for the cache
});

// somewhere in the abyss of my service worker...

cache.put("https://example.com", new Response("Hello World!"));

Some Advanced Stuffs

How does RemixCache work? Can I use it with the normal browser CacheStorage API?

Answering the last question first, yes you can! RemixCache is a wrapper around the Cache API, so it's just a normal cache.

First question is a bit more tricky. A little backstory might help with the understanding of how RemixCache works.

A little backstory on how the cache package came about

I got the idea for RemixCache from cachified, after trying to unsuccessfully bring more flexibity to the Cache API in Remix PWA v2, I decided to re-attempt it in Remix PWA v3. Looking at a few caching solutions, and testing them out with the new APIs you are looking at before you, I stumbled upon cachified.

cachified is a library for caching with your favourite caching solution, it allows you to cache via memory and Redis out-of-the-box. Plus, it's extensibility means you can create adapters for it to cache with any other solution you want. I tested it out by creating an adapter for the Cache API and it worked like a charm.

Next up was figuring how to replicate it. After a bit of inspection, I realised that cachified actively modified the response (value) before storing it. It was genius! You had your response which looked like this:

const response = new Response(value, {
  headers,
  status,
  statusText
});

now wrapped into something like this:

const response = new Response(
  JSON.stringify({
    value,
    metadata: {
      /* Metadata. Where magic occured */
    }
  }),
  {
    headers: {
      "Content-Type": "application/json",
      headers
    },
    status,
    statusText
  }
);

I fell in love with the implementation. It was simple, fast enough and it worked. So I decided to go along with it.

I hope you read the backstory, if you didn't, you should. It's important to understanding how RemixCache works. Now, let's get to the main question.

Looking at the backstory, you would see the new schematics that Response were now wrapped with. It is all about the metadata. RemixCache works by simply comparing, deleting, adding, sorting based on the metadata. That way, you can introduce expiration, size limits and more to your cache.

Can you interface with it directly? Sure thing! RemixCache is just a normal cache, so you can use it with the CacheStorage API. To use it, you simply need to remember that all caches of RemixCache instance are prefixed by rp- and the name of the cache. So if you have a cache called my-cache, you can access it via caches.open('rp-my-cache'). You can also use Storage.open('my-cache') to open it.

Looking at the model in the backstory above, I suppose I can leave you to your whims, fancies and imagination on what to do now that you have a clearer picture of how RemixCache works. I hope you enjoy using it as much as I enjoyed creating it for you ❤️.


API Reference

RemixCache

RemixCache is the star of this package. The wrapper around the Cache API that allows us to store and retrieve responses from the cache with more refined control.

Currently, instantiation of RemixCache directly is impossible. And it was a purposeful decision, you can only nteract with via the Storage API. This is to ensure that all caches are wrapped and you don't have to worry about it getting inconsistent later on.

First of all, RemixCache implements the following interface:

interface CustomCache extends Omit<Cache, "addAll" | "matchAll"> {
  put(request: RequestInfo | URL, response: Response, ttl?: number | undefined): Promise<void>;
}

It re-implements the put method of the Cache API to allow for expiration of items in the cache. It also adds a ttl parameter to the method to allow you specify the time to live of that particular item in the cache. If you don't specify a ttl, the item will utilise the general ttl of the cache.

The other methods work the same way and accept the same arguments like the Cache API. Check out the MDN docs for more info.

A look at the constructor of RemixCache:

enum Strategy {
  CacheFirst = 'cache-first',
  NetworkFirst = 'network-first',
  CacheOnly = 'cache-only',
  NetworkOnly = 'network-only',
  StaleWhileRevalidate = 'stale-while-revalidate',
}

constructor({
    name: string;
    strategy?: Strategy;
    maxItems?: number;
    ttl?: number;
}: RemixCacheOptions) {}

You should know!

Whilst runtime caching strategies in Remix PWA are handled via the @remix-pwa/strategies package, the strategy passed in here is useful (quite useful actually) when utilising it with tools like cachified (which is supported out of the box!).

The constructor accepts an object with the following properties:

  • name: The name of the cache. This is used to identify the cache in the CacheStorage API.
  • strategy: The caching strategy to use. This is useful when utilising RemixCache with cachified. Defaults to Strategy.NetworkFirst.
  • maxItems: The maximum number of items to store in the cache. Defaults to 100.
  • ttl: The default time to live of items in the cache. Defaults to Infinity.

If you have any further questions or feel this section can be improved, please feel free to open an issue or PR.

RemixCacheStorage

RemixCacheStorage is a static class that allows us to create and manage multiple caches of RemixCache instances. A good question you might be having now is why not a wrapper around CacheStorage API. Good question, I have no idea myself. Wrapping it would mean making it a non-static class though, and require you to instantiate it and pass it to the route workers via the getLoadContext function (plus, it might be restricted to just the worker thread). If you think that's a better idea, I would be happy to hear your thoughts on it (RFCs are welcome!).

As mentioned earlier, RemixCacheStorage is alias to Storage. You can use either of them, they are the same thing.

RemixCacheStorage implements the following interface:

interface CustomCacheStorage {
  createCache(opts: RemixCacheOptions): Promise<RemixCache>;
  has(name: string): Promise<boolean>;
  get(name: string): RemixCache | undefined;
  open(name: string, opts?: Omit<RemixCacheOptions, 'name'>): RemixCache
  delete(name: string);
  clear();
}

And a few more that if you ask me, best to stay away from them.

The methods are pretty self-explanatory, but I would explain them anyway.

  • createCache: Creates a new cache with the specified options. Returns a promise that resolves to the created cache.
  • has: Checks if a cache with the specified name exists. Returns a promise that resolves to a boolean.
  • get: Gets a cache with the specified name. Returns the cache if it exists, else undefined.
  • open: Opens a cache with the specified name. Returns the cache if it exists, else creates a new cache with the specified options and returns it.
  • delete: Deletes a cache with the specified name.
  • clear: Clears all caches.

Also a few things to note:

  • open is preferred over createCache as it is more performant. Use whenever you can.
  • @remix-pwa/cache also ships with two functions: initCache and createCache. Both are aliases to each other and are short hand for Storage.createCache.

cachified

We haven't forgptten cachified, in fact, we shipped an adapter just for it! @remix-pwa/cache ships with an adapter for cachified that allows you to utilise the Cache API (utilizing RemixCache ofc). It also ships with a wrapper around cachified itself that provides you a short-hand to use it. It has the following interface:

export type CachifiedWrapperOptions = {
  key: string;
  cache: RemixCache;
  getFreshValue: GetFreshValue<any>;
  swr?: number;
  ttl?: number;
  staleRefreshTimeout?: number;
  reporter?: CreateReporter<any>;
};

const cachifiedWrapper = (opts: CachifiedWrapperOptions) => {};

The cachifiedWrapper function accepts an object with properties similar to the cachified options. Check them out here.

The key thing to note is that the strategy parameter (of RemixCache) is important here. For example, if you utilise a NetworkOnly strategy, you would not be able to cache anything because the ttl would always be -1 (meaning, don't cache).

Also, if you utilise any strategy other than StaleWhileRevalidate, swr would not be applicable.

remixCacheAdapter

The remixCacheAdapter is useful when you want more control over cachified and want to use it with cachified directly.

It is used to wrap RemixCache into something cachified can interface with and take in one parameter: cache which is the RemixCache instance.

import { remixCacheAdapter } from '@remix-pwa/cache'

const remixCache = await Storage.open('my-cache');

const cache = remixCacheAdapter(caremixCacheche);

await cachified({
  cache,
  key: request.url,
  getFreshValue() {
    return fetch(request);
  },
});