PWA: Detecting redirects in service workers

PWA app

Why do you need to detect redirects in a PWA page? Well a PWA is established on a specific domain. You might need to redirect to a new domain at some point. You can easily do it server side with simple commands, but as PWA is cached in a specific way it will remain on the old domain. PWA will not be able to do calls to redirected API because of CORS. And it might never move on because when your admin redirected domains you will not be able to get a new service worker either. So you need to think ahead and plan for this. But how? Can you detect redirects, especially CORS redirects in JavaScript?

Redirects and request state

You might think that detecting redirects is easy – just do a an XHR or fetch and check HTTP status code, right? Wrong, sorry. Not that easy. By default all redirects are resolved in both XHR and fetch. This is done in a very early stage. Between readyState = 1 (opened request) and readyState = 2 (headers received).

HEADERS_RECEIVED (numeric value 2):
All redirects (if any) have been followed and all headers of a response have been received.")

Source: xhr.spec.whatwg.org/#states

This means that your browser will try to go destination site and will fail due to CORS. So in xhr.onreadystatechange you will go straight from opened (1) to done (4). You will also get an error event. For fetch this is similar, but you will get a TypeError with a message a bit depended on a browser (different in Chrome and Firefox).

This is not useful because you get the same error when CORS fails and when the server is not working. And with PWA your app should still be usable when the server is offline. In either case status = 0 and headers are removed. Annoying, right? I agree, but this is due to security reasons.

Exposing redirects might leak information not otherwise available through a cross-site scripting attack.

Example:
A fetch to https://example.org/auth that includes a Cookie marked HttpOnly could result in a redirect to https://other-origin.invalid/4af955781ea1c84a3b11. This new URL contains a secret. If we expose redirects that secret would be available through a cross-site scripting attack.

Source: fetch.spec.whatwg.org/#atomic-http-redirect-handling

So that is a no-go for detecting CORS redirects. It also means there will probably never be a way to detect the destination URL of a redirect.

How do you detect a redirect

At this point we know XHR is no use for us. So how about a newer Fetch API? Above still mostly applies. Response status will be 0 and headers are not available. But there is a special fetch request that doesn't resolve redirects.

So to detect a redirect you need to:

  • Use fetch() request.
  • You should use a light request so method: 'HEAD' will be good for that.
  • You should probably avoid cache in this request by using cache: 'no-cache' option.
  • And most importantly you must use redirect: 'manual' option.
  • After doing the fetch request you just need to check response.type. It will be opaqueredirect when a response indicated a redirect.

Note that for this request headers will still not be available. For the same security reasons. So you will not be able to get a Location header, but at least you know the redirect occurred. I don't agree with the specs here. It should be website dependent. Not all websites redirect with a session ID in the URL... But hey, you can shout it out on Twitter or Tiktok to some dev evangelist 🙂.

Specification for redirect fetch option and opaqueredirect response type:

The redirect testing code

Note that this will brake JavaScript in the Internet Explorer. So if you care about IE users getting some JS, then load this in a separate <script> tag.

This is also not a final code I used, but it is close. It has some extra logging so you can see what is going on. Remember to at least change url to something specific to your website.

/**
    Redirect detection testing.
    (not a final impl, just a good code for browser behaviour testing)
*/
async function isRedirect(url) {
    // request that is designed to be as light as possible
    const response = await fetch(url, {
        // this is crucial
        redirect: 'manual',
 
        /**
            Use HEAD method for a light request and response.
 
            This should also skip SW caches
            (you typically only cache GET requests in SW)
        */
        method: 'HEAD',
 
        // make sure HTTP cache is skipped
        cache: 'no-cache',
 
        // below potentially makes the request light, but might change server behaviour...
        // YMMV
        mode: 'no-cors',
        credentials: 'omit',
        referrerPolicy: 'no-referrer',
    });
    console.log('[isRedirect]', response);
    console.log('[isRedirect]', 'Crucial state:', {
        status: response.status,
        type: response.type,
        url: response.url,
    });
    if (response.type == 'opaqueredirect') {
        return true;
    }
    return false;
}
 
var url = '/templates/templates.html';
// Note that you have to catch network errors here (you cannot use `await`).
var result = false;
isRedirect(url).then((isRedirectResult) => {
    result = isRedirectResult;
    console.log('request ok, redirect:', isRedirectResult);
    console.log('result:', result, url);
}).catch((error) => {
    console.error('error caught:', error);
    console.log('result:', result, url);
});

As a side note: I used var variables in global scope intentionally. If you haven't noticed already, note that you cannot replay (re-run) code that uses let variables.

Clearing caches when you redirect

So you might use above as a base for your own class or just use this quick snippet:

fetch(urlForTestingRedirs, {
    redirect: 'manual',
    method: 'HEAD',
    cache: 'no-cache',
}).then(function(response) {
    if (response.type == 'opaqueredirect') {
        clearAllForPwa()
    }   
});

You do have to define clearAllForPwa though. The function will be specific to your application, but:

  • You should unregister your service worker. Going through navigator.serviceWorker.getRegistrations() should work fine. But just make sure getRegistrations is a function (or try-catch over that).
  • Clear Cache API (aka SW Cache). You can do it as I described in my article: PWA and HTTP Caching (look for clearServiceWorkerCache function).
  • Clear your Web Storage API (if you use that). Should be as simple as running localStorage.clear().
  • Clear your IndexDB or any other storage you use. For IDB I would definitely use a library, because IDB is just a horrible API... So if you use localforage it should be as easy as running localforage.clear(). Note that after running this (even if you wait) a IDB database will not be removed until you leave the page. That is expected and don't worry about that too much.

So next time your user will do a navigation the redirect will just work. You might also pop-up some message about errors with a reload button. You should probably wait until all caches are clear though.

So that's it. Not that hard once you know you have to use that one specific option of fetch() (and that you cannot use headers nor status).