This review is specifically about how to handle fetch requests in a Service Worker.
There are a number of basic strategies that are fairly standard for any website. You can also create your own more specific ones for any app that you build.
Regardless of which strategy that you like to use, there will never be a single strategy that you use for every request on any website.
You can create a single function for each strategy for easy reuse.
This strategy is used when you only want to send a response from a Cache, no network requests involved.
function cacheOnly(ev) {
//only the response from the cache
return caches.match(ev.request);
}
This strategy is used when you want to check the cache first and then only make a network request if the cache request fails.
function cacheFirst(ev) {
//try cache then fetch
return caches.match(ev.request).then((cacheResponse) => {
return cacheResponse || fetch(ev.request);
});
}
This strategy is used when you only want to make fetch requests and you want to ignore the cache entirely.
function networkOnly(ev) {
//only the result of a fetch
return fetch(ev.request);
}
This strategy is used when you want to make fetch requests but fall back on the cache if that fails.
function networkFirst(ev) {
//try fetch then cache
return fetch(ev.request).then((response) => {
if (response.status > 0 && !response.ok) return caches.match(ev.request);
return response;
});
}
This strategy involves using both the cache and the network. The user is sent the copy in the cache if it exists. Then, regardless of whether or not the request exists in the cache, a fetch request is made. The new fetch response is saved in the cache to be ready for the next request.
function staleWhileRevalidate(ev) {
//return cache then fetch and save latest fetch
return caches.match(ev.request).then((cacheResponse) => {
let fetchResponse = fetch(ev.request).then((response) => {
return caches.open(cacheName).then((cache) => {
cache.put(ev.request, response.clone());
return response;
});
});
return cacheResponse || fetchResponse;
});
}
This strategy is similar to the Stale While Revalidate strategy except it prioritizes the fetch request over the current value in the cache.
function networkFirstAndRevalidate(ev) {
//attempt fetch and cache result too
return fetch(ev.request).then((response) => {
if (response.status > 0 && !response.ok) return caches.match(ev.request);
//accept opaque responses with status code 0
//still save a copy
return cache.put(ev.request, response.clone()).then(() => {
return response;
});
});
}
When you want to select a different strategy for each file you need to have information about each Request in order to make those decisions.
The ev.request
object that we get from the Fetch Event contains lots of information that we can use for decision making.
let mode = ev.request.mode; // navigate, cors, no-cors
let method = ev.request.method; //get the HTTP method
let url = new URL(ev.request.url); //turn the url string into a URL object
let queryString = new URLSearchParams(url.search); //turn query string into an Object
let isOnline = navigator.onLine; //determine if the browser is currently offline
let isImage =
url.pathname.includes('.png') ||
url.pathname.includes('.jpg') ||
url.pathname.includes('.svg') ||
url.pathname.includes('.gif') ||
url.pathname.includes('.webp') ||
url.pathname.includes('.jpeg') ||
url.hostname.includes('some.external.image.site'); //check file extension or location
let selfLocation = new URL(self.location);
//determine if the requested file is from the same origin as your website
let isRemote = selfLocation.origin !== url.origin;
When you want to check if the browser is online or offline, the navigator.onLine
property tells you absolutely if you are offline. The online value being true can be deceptive. So, it would be better to also do a test. Pick an endpoint that you know always works efficiently and do a fetch call with the HEAD
method. If that returns successfully, then you are definitely online.
async function isConnected() {
//can only be called from INSIDE an ev.respondWith()
const maxWait = 2000; //if it takes more than x milliseconds
if (!navigator.onLine) return false; //immediate response if offline
//exit if already known to be offline
let req = new Request('https://jsonplaceholder.typicode.com/users/1', {
method: 'HEAD',
});
let t1 = performance.now();
return await fetch(req)
.then((response) => {
let t2 = performance.now();
let timeDiff = t2 - t1;
// console.log({ timeDiff });
if (!response.ok || timeDiff > maxWait) throw new Error('offline');
return true; //true if navigator online and the HEAD request worked
})
.catch((err) => {
return false; //the fetch failed or the response was not valid
});
}
You can also add a timer to that response. If you don't get a HEAD
response quickly then you have a poor connection.
REMEMBER to add a URL that you trust to this Request.
You might be able to save a few milliseconds by adding a preconnect in your HTML head with the same domain.
<link rel="preconnect" href="https://jsonplaceholder.typicode.com" crossorigin />
You can optionally restrict the maxWait
value to a lower number of milliseconds. Exceeding this value when calculating the difference between two performance.now()
calls means that you can also treat slow connections as offline too.
performance.now()
gives you an accurate number of milliseconds since the service worker started running. Call this before and after your HEAD
fetch and it will tell you how long it took.
If you want to use this method, remember that it can only be called from INSIDE a ev.respondWith( )
. You need to make it the first step in a chain of .then( ).then( ) calls. Remember to return at each level of nesting.
In the sample version of sw.js
this is being demonstrated inside the networkFirstCred
function.
Inside the fetch event listener function you will likely have many if statements, switch case statements, nested if statements, and logical short-circuiting.
Treat the respondWith()
method calls like function return
statements. The first one that is encountered will send back the Response. However, the code in your function will continue to run after
it is called. So, always have an else{ }
block. Don't make two respondWith
calls in the same block.
self.addEventListener('fetch', (ev) => {
let isOnline = navigator.onLine;
if (isOnline) {
ev.respondWith(fetchOnly(ev));
} else {
ev.respondWith(cacheOnly(ev));
}
});
There will also be times where you want to look at the Response
object. You might want to check its headers and return different things depending on those values.
fetch(ev.request).then((response) => {
let hasType = response.headers.has('content-type');
let type = response.headers.get('content-type');
let size = response.headers.get('content-length');
console.log(type, size);
});
Sometimes, when you are making fetch calls you will get an Opaque
response. This is a valid response but one that cannot be read by JavaScript. You cannot use JavaScript to get the values of the
headers or call .text()
or .json()
or .blob()
on. This happens when you are making a cross-origin request. Eg: trying to get an image file from a website that is not your own.
The way to avoid some of the security issues is to stop credentials and identifying information from being passed to the external web server. We do this by setting the credentials
setting on the
request to omit
. This will prevent the information being sent and solve some of the errors that you see with external requests.
self.addEventListener('fetch', (ev) => {
//ev.request is the request coming from the web page
//we can change its settings when making a fetch( ) call
ev.respondWith(
fetch(ev.request, {
credentials: 'omit',
})
);
});
There are times when an Opaque
response is not a problem. If the Request has a destination
property set to image
then we have no issue. The HTML is allowed to load and use items like images or fonts when they are Opaque
. It is the JavaScript that is not allowed to use Opaque
responses.
To close this potential loop hole, You are not allowed to set the value of the destination
header from JavaScript. Only the browser can set it. The destination property of the Request
may be audio
, audioworklet
, document
, embed
, font
, frame
, iframe
, image
, manifest
, object
, paintworklet
, report
, script
, sharedworker
, style
, track
, video
, worker
or xslt
strings, or the empty string, which is the default value.
If the destination is blank then the request is coming from a fetch()
call in your script, Cache request, beacons, WebSockets, or a few other things.
The blank response or a response whose destination is script
or *-worklet
or *-worker
will have tighter CORS restrictions.
You can also change SOME of the other settings like Headers
in the Request before it is sent to the server.