How to know if a progressive web app is in foreground or background

From a client you can use the Page Visibility API to query the current visibility and to get events when the visibility changes.

const state = document.visibilityState;

document.addEventListener('visibilitychange', () => {
  console.log(document.visibilityState);
});

From Service Workers you can also query the visibility state of clients with WindowClient.

event.waitUntil(
  clients.matchAll({ type: 'window' })
    .then(function(clientList) {
      for (var i = 0; i < clientList.length; i++) {
        const state = clientList[i].visibilityState;
      }
    })
);

I've searched all over for this for days, lots of the same answer as per above. Whilst the visibility api does work to an extent, it doesn't work all the time. Thought I'd post our solution here to supplement the answer above with what we have found to be the most reliable way that works across most browsers.

The issue for us was on mobile devices more than anything else. We have a PWA that is installable, but the problem exists in the mobile browser as well. Once the installed PWA (or tab open in mobile browser) is pushed into the background, the visibility API isn't sufficient on it's own. Our app relies on realtime data updates via graphql subscriptions, when the app/browser goes into the background, it no longer gets this data. If user was to then open the app after getting a push notification indicating they have a new message for example, the new message isn't in the app as it's effectively been asleep and it's websockets closed. We don't want to have to do a full refresh everytime the user moves between our app and other apps on their device, so using the low level check in the service worker and just reloading the page, wasn't going to provide a great experience and cause a lot of unnecessary loading. We needed to reliably know inside our react components, when the app had come back into the foreground, so that we could force a fetch of relevant missing data from when it went into the background.

We ended up adapting this from the google documentation on page lifecycle. Someone did create a small js library for it, but we couldn't get that to work. So we just adapted the example code and it's been working pretty well.

https://developers.google.com/web/updates/2018/07/page-lifecycle-api

The below code can easily be integrated into a react component and managed through useState and useEffect hooks.

import MobileDetect from 'mobile-detect';
const md = new MobileDetect(window.navigator.userAgent);
const isMobile = md.mobile();

let standalone
if (navigator.standalone) {
  standalone = 'standalone-ios';
}
if (window.matchMedia('(display-mode: standalone)').matches) {
  standalone = 'standalone';
}

const getState = () => {
  if (document.visibilityState === 'hidden') {
    return 'hidden';
  }
  if (document.hasFocus()) {
    return 'active';
  }
  return 'passive';
};

let displayState = getState();

const onDisplayStateChange = () => {
  const nextState = getState();
  const prevState = displayState;

  if (nextState !== prevState) {
    console.log(`State changed: ${prevState} >>> ${nextState}`);
    displayState = nextState;

    //standalone will restrict to only running for an installed PWA on mobile
    if (nextState === 'active' && isMobile /* && standalone */) {  
      //The app/browser tab has just been made active and is visible to the user
      //do whatever you want in here to update dynamic content, call api etc
    }
  }
};

//subscribe to all of the events related to visibility
['pageshow', 'focus', 'blur', 'visibilitychange', 'resume'].forEach((type) => {
  window.addEventListener(type, onDisplayStateChange, {capture: true});
});