A request cache for client side apps in under 40 LOC

Published on August 24, 20205 min read

Caching and de-duping HTTP requests helps facilitate local reasoning. This means that you think about a part of your code in isolation, without holding the entire system in your head. I'll get into more of why this has a huge impact on the way you code at the end of this post.

Caching responses

A class works well here since our cache contains an object to store requests and methods to add and remove requests from it. The createRequest method generates a unique key for the request and inserts it into the cache.

cache.js
import { getQueryHash } from 'utils.js'

class Cache {
  queries = {} // Object to store queries in

  createRequest(queryFn, url, params) {
    // Get a unique hash for this query
    let hash = getQueryHash(url, params)

    // If the response for the request is already in
    // the cache, then return that
    if (this.queries[hash]) {
      return this.queries[hash].response
    }

    // Call the query fn with params
    let promise = queryFn(url, params)

    promise
      .then((response) => {
        // Once the promise resolves store
        // the response into the cache
        this.queries[hash] = {
          response,
        }
      })
      .cache(() => {
        // If the request fails,
        // remove it from the cache
        delete this.queries[hash]
      })

    // Return the promise so user can
    // get the response once it resolves
    return promise
  }
}

getQueryHash is a function that returns a deterministic hash based on the URL and params. We sort the params, stringify them and then append that to the URL and now we've got a unique key for a URL.

utils.js
export function getQueryHash(url, params) {
  let paramString = JSON.stringify(
    Object.keys(params)
      .sort()
      .map((key) => ({ [key]: value[key] }))
  )

  return `${url}:${paramString}`
}

De-duplicating requests

A simple optimization we can add is de-duplicating requests by storing the promise returned by queryFn. We'll also remove the promise once it resolves.

// If the same query is called again, we can re-use the
// same promise if this request has not yet completed
this.queries[hash] = {
  response: null,
  promise,
}

promise.then((response) => {
  this.queries[hash] = {
    response,
    promise: null, // Remove the promise
  }
})

Now, when the same request is made again, we can just return the existing promise.

let hash = getQueryHash(url, params)

if (this.queries[hash]) {
  let { promise, response } = this.queries[hash]

  // If there is an ongoing request for the same
  // hash, then return stored promise, else return
  // response
  if (promise) {
    return promise
  } else {
    return response
  }
}

// rest of the code...

Cache Invalidation

Checking if a request exists in cache or removing one from the cache is pretty straightforward. But in most cases, you'd usually want the cache to expire based on a timeout. We'll introduce a cacheTimeout option that'll lets us specify how long a request should be persisted in the cache.

// Create a timeout fn that clears this query
// from the cache based on config.cacheTimeout
let timeoutFn = setTimeout(() => {
  delete this.queries[hash]
}, cacheTimeout || 5 * 1000) // defaults to 5 sec

// We'll store timeoutFn so that we can clear
// it if required
this.queries[hash] = {
  response: null,
  promise,
  timeoutFn,
}

We'll also add a force option to force cache invalidation for a query.

if (config.force) {
  // clear the timeout and remove the request from the cache
  // so that a new request is started
  clearTimeout(this.queries[hash].timeoutFn)
  delete this.queries[hash]
}

Essentially createRequest is just a higher-order function that manages the cache while you get to make requests like you usually do.

cache.js
class Cache {
  queries = {}

  createRequest(queryFn, url, params, config) {
    let hash = getQueryHash(url, params)
    let promise = queryFn(url, params)

    if (config.force) {
      clearTimeout(this.queries[hash].timeoutFn)
      delete this.queries[hash]
    } else if (this.queries[hash]) {
      let { promise, response } = this.queries[hash]

      if (promise) {
        return promise
      } else {
        return response
      }
    }

    let timeoutFn = setTimeout(() => {
      delete this.queries[hash]
    }, config.cacheTimeout || 5 * 1000)

    this.queries[hash] = {
      response: null,
      promise,
      timeoutFn,
    }

    promise.then((response) => {
      this.queries[hash] = {
        ...this.queries[hash],
        response,
        promise: null,
      }
    })

    return promise
  }
}

Using the cache

We'll be using axios for making the requests but this should work with fetch or anything else you want to use. Since we don't really do anything with the response, it'll be the same as what you'd have if you'd used axios directly.

MyComponent.js
import axios from 'axios'
import cacge from 'cache.js'

function loadMovie(ID) {
  cache
    .createRequest(axios.get, 'someurl/api/movies', {
      params: { ID },
    })
    .then((response) => {
      // do something with resonse
    })
    .catch(() => {
      // handle error
    })
}

You could also combine it with the bridge pattern where all this would be wrapped up inside a data-layer. This decouples the cache implementation (and axios) from your application code.

api.js
import axios from 'axios'

export const API = {
  get(url, params) {
    return createRequest(axios.get, url, { params })
  },
  post(url, payload) {
    return axios.post(url, payload)
  },
}
MyComponent.js
import API from './api'
...
loadData() {
  API.get(url, params).then((response) => {
    // do something with response
  })
}

Locally reasoning about data

Most of modern front end development is centered around writing components. And when you build large apps it becomes more and more difficult to hoist data fetching logic higher up the component hierarchy. Even if that's not a problem, having a component fetch data unrelated to it makes code harder to maintain. Or you might want your components to work in isolation to make sure they can be dropped onto anywhere in your app.

The obvious alternative here is to build "smarter" components that fetch all the data they need. But then there's no control over how many API calls a page has and you end up with duplicate requests. Our cache fixes this because it makes sure requests are not duplicated.

Harris Jose
Software Engineer at Chronicle HQ.

Contact

Now

Whipping up a text editor and thinking about presentations on the web. Getting my hands dirty with Node.js and Rust.
Not Playing