Avoid repeated identical fetch() calls in a short amount of time
spa-fetch is a wrapper to Javascript’s native fetch() call which will prevent multiple fetch() GET calls being made
against the same URL in a short amount of time.
It also provides hooks which will allow specific code to be run before and after each fetch call.
Use cases
You may have a SPA (Single Page Application) made up of fully decoupled components that use the browser
location to load data. If two decoupled components are nested location-wise (e.g. one is /users/:userId and the other one
is /users/:userId/address/:addressId), they may both attempt to make a request for /users/10 (assuming that userId is 10)
within a short amount of time.
You may have an application that in which the user can trigger an HTTP GET call via UI, and want to make sure that each call behaves as if the server had responded, without overwhelming the server with requests.
You may have an SPA and want to centralise the UI response for specific fetch errors. For example a 401 could trigger a dialog to re-login.
Configuring the function
spa-fetch can be configured by simply changing the exported object spaFetchConfig, defined like so:
export const spaFetchConfig = {
cacheDuration: 1000,
fetcher: null
}
const config = spaFetchConfig
Creating a unique string out of fetch()‘s parameters
One of the main goals of this module is to cache HTTP GET requests. Therefore, the module must be able to tell whether two
fetch() calls were made with the same parameters.
This is normally achieved by creating a unique hash based on the passed parameters – matching hashes will imply matching requests.
Unfortunately, there is a considerable level of complication since fetch() can effectively be used in three
different ways, each one requiring a different way of creating the hash.
Consider that the parameters are resource and init, the parameters can be defined in three ways:
resourceis a URL string, andinitis an object with specific properties. In this case, the browser will create aRequestobject internally, based on the passed URL string and theinitparameters. The parameters required byRequest‘s constructor match the ones infetch(). This means that the browser, behind the scenes, will simply callnew Request(resource, init)ifresourceis an URL string. In terms of creating the hash, this is the simplest case.resourceis aRequestobject. In this case, the browser will have no need to create aRequestobject, since the developer has one already created. In terms of creating the hash, this is a very difficult scenario since serialising aRequestobject needs to be done knowing exactly which properties are important. Things are complicated by the fact that the propertiesbodyandheadersare special cases (bodyis exposed as a stream, andheadersis a Map).resourceis aRequestobject, andinitis an object. In this case, the browser will somehow create a newRequestobject usingresourceas a starting point, but with the properties ininitapplied to it too. This means that theRequestobjectresourcemight have thecacheproperty set as default. However, sinceinitcontains{ cache: 'no-cache' }, the finalRequestobject will actually haveno-cacheset for thecacheproperty – basically, theinitobject has the last say.
The hashing needs to work reliably for two requests with identical parameters even in cases where those parameters are set using different patterns seen above. For example, the hashes need to match for these two requests:
// `resource` is a URL string, and `init` is an object
const res1 = await spaFetch('http://www.google.com', { cache: 'reload', headers: { 'x-something': 10 } })
// `resource` is a Request object created with cache as `reload`, and then
// spaFetch called with `init` where cache is `reload`
const request2 = new Request('http://www.google.com', { cache: 'no-cache', headers: { 'x-something': 10 }})
const res2 = await spaFetch(request2, { cache: 'reload'})
This is an extreme example, but it shows how request2‘s property for cache is then overridden by the
prop variable passed to spaFetch.
The best way to have reliable comparisons is to always create a Request object (even when spaFetch() is called with resource being
a URL string), and comparing the relevant properties from the newly created Request object.
This is done in two blocks of code; they both aim at creating two variables finalInit and finalUrl which
will be used to create the hash.
Here is how it works:
function makeHash (resource, init) {
const finalInit = {}
let finalUrl = ''
let finalRequest
const allowedInitProperties = ['method', 'mode', 'credentials', 'cache', 'redirect', 'referrer', 'integrity', 'headers']
This is the full list of properties which make a request unique. Note that body is missing, since spaFetch() will only
ever cache GET requests. (Luckily so: body is also defined as a stream in a Request object, and it would me difficult to
serialise).
The first case considered is where the resource parameter is a URL string, rather than a Request:
/* FIRST PARAMETER IS A URL! */
/* ---------------------------- */
if (!(resource instanceof Request)) {
finalRequest = new Request(resource, init)
for (const prop of allowedInitProperties) finalInit[prop] = finalRequest[prop]
finalUrl = finalRequest.url
This is the simple case. A new request is created, based on the resource (which is a URL string) and the init object.
While this may seem wasteful, it will ensure that any kind of property normalisation carried out by the Request constuctor
doesn’t affect comparison.
So, first a new request is created (in finalRequest). Then finalInit is created, by talking all of the allowed init
properties over from the newly created Request. Finally, finalUrl is set, taken from the url property of the newly
created request (finalRequest.url).
A much more involved process is needed in case the resource parameter if an instance of Request:
/* FIRST PARAMETER IS A REQUEST! */
/* ----------------------------- */
} else {
const originalRequest = resource
if (!init) {
finalRequest = resource
for (const prop of allowedInitProperties) finalInit[prop] = originalRequest[prop]
finalUrl = finalRequest.url
} else {
const originalRequestInit = {}
for (const prop of allowedInitProperties) originalRequestInit[prop] = originalRequest[prop]
finalRequest = new Request(originalRequest.url, { ...originalRequestInit, ...init })
for (const prop of allowedInitProperties) finalInit[prop] = finalRequest[prop]
finalUrl = originalRequest.url
}
}
In this case, there are two distinct possibilities: one where the init object is passed, and one where it’s not.
The first case is the easy one: the code is identical to the previous case, with the exception that the Request object
doesn’t need to be created (since it was passed).
The second case, where init was passed, is much more involved. The passed Request does not have all of the properties
needed, since there is a second init parameter that will affect those properties.
The solution is to first create an originalRequestInit based on the original, passed Request object; then, another request
called finalRequest is created, using the URL parameter from the original request, and – as properties – using the
original originalRequestInit object mixed with the passed init object
(That is, new Request(originalRequest.url, { ...originalRequestInit, ...init })). Finally, the finalInit variable is created
based on the important properties of that newly created request.
The browser is likely to do something very similar when is passed a Request object and and init object to the fetch() function.
At this stage, the two crucial variables finalInit and finalUrl are set.
First of all, if the method is different to GET (in capital letters, as it was normalised by the browser itself), then the function
will return an empty string. This will mean ‘no caching’:
if (finalInit.method !== 'GET') return ''
Also, any empty value is filtered out of finalInit:
for (const k in finalInit) {
if (typeof finalInit[k] === 'undefined') delete finalInit[k]
}
This is the function’s home stretch. There is yet one last gotcha: the headers property behaves like a map, rather than like
an object or an array. That Object.fromEntries() will ensure that it’s converted into an object before running canonicalize() on it:
finalInit.headers = canonicalize(Object.fromEntries(finalInit.headers))
const items = canonicalize(finalInit)
Thanks to canonicalize() (explained in the next paragraph), the items variable is an array. The last step is to add the URL to it,
and return the stringify() version of it: the work is done.
items.unshift(finalUrl + ' ')
return JSON.stringify(items)
}
The lines above use the canonicalize() function to convert a parameter into an array. The reason this happens, is to ensure that
an object will be converted in such a way so that the result of stringify() will be the same regardless of the order the properties
were defined in. The problem is that JSON.stringify({a:10, b: 20}) returns something different to JSON.stringify({b:20, a: 10}).
This function ensures that an object is converted into an array with properties sorted, which will ensure that JSON.stringify() will
return the same value regardless of the order in which the properties were defined.
function canonicalize (obj) {
if (typeof obj === 'object') {
if (obj === null) return null
const a = Object.entries(obj)
return a.sort((a, b) => b[0].localeCompare(a[0]))
}
/* Not an object: return as is */
return obj
}
The implementation of spaFetch() can be broken down into several steps:
- Create the hash of the call. This is done with the function makeHash seen above
- Clean up the cache (stores in
spaFetch.cache) of expired entries - Check if the item is already in the cache. If it is, return a clone of the response in the cache and end the process right there
- Actually run
fetch()with the passed parameters, and save the promise. If it’s to be cached, cache it - Return a clone of the response
Here is the code explained, step by step.
First of all, the hash is created, as well as the now variable (which will be handy later)
export const spaFetch = async (resource, init = {}) => {
const hash = makeHash(resource, init)
const now = Date.now()
This maintenance cycle is run every time spaFetch() is called. This could be done with a
setTimeout(), but it’s easier to do it each time to prevent build-ups.
for (const [hash, value] of spaFetch.cache) {
if (value.expires < now) {
spaFetch.cache.delete(hash)
}
}
If the entry is to be cached (meaning, hash is not empty), the code will look in the cache
for it.
If present, and it’s not expired, then a new promise is returned. This part is critical: if the
response were to be returned straight away, then any call to await response.json() to actually get
the data would only work the first time it’s run. This means that subsequent calls getting the
response for the caches would be unable to use it for anything useful.
This is why rather than returning the response, it returns a promise that will resolve with the
clone of the response returned by the fetch promise.
So:
- The cache always contains the
promisereturned byfetch(), stored asfetchPromise - When hitting the cache, what is actually returned is a promise that will call
fetchPromise.then()and, once it gets the reponse, it willcloneit and return it - If there is an error, it will reject the promise with the same error.
This means that the returned promise will work exactly as the one returned by fetch() for
all intents and purposes, with the difference that the response returned is a clone of the
one in the cache.
Here is the code:
if (hash) {
const cachedItem = spaFetch.cache.get(hash)
if (cachedItem && cachedItem.expires >= now) {
return cachedItem.fetchPromise.then(response => response.clone())
}
}
If the call gets to this stage, it means that it wasn’t in the cache. This means that it will need to be called.
Note that developers are able to change the module’s configuration to use a different fetching function (which
will be expected to return a fetch() promise)
let fetchPromise
if (config.fetcher) {
fetchPromise = config.fetcher(resource, init)
} else {
fetchPromise = fetch(resource, init)
}
If the item is to be cached (see: hash is not empty), it will do so:
if (hash) {
spaFetch.cache.set(hash, { fetchPromise, expires: now + config.cacheDuration })
}
Even if the cache was empty, it’s still paramount to return a proxy promise (as explained above) rather than
the original fetch() promise, in order to prevent the case where await response.json() is called – and the cached
value is rendered useless.
return fetchPromise.then(response => response.clone())
}
The cache is a property of the spaFetch() function. This helps with testing
spaFetch.cache = new Map()
Conclusions
Writing this module had two distinct challenges. The first one, was the creation of a hash function that
really worked regardless of the way fetch() was used. The second one, was to return a promise
that worked exactly like fetch(), although only one actual call was made.
The end result is something that can facilitate the creation of decoupled components which might end up making the exact same network request at the same time.
Note: there is a code review happening. Also, this module is the result of this StackOverflow question