Diogo Spínola I'm a learning enthusiast, web engineer, and blogger who writes about programming stuff that draws my attention.

Notifications, caching, and messages in a progressive web app (PWA)

7 min read 2117

Notifications, caching, and messages in a progressive web app (PWA)

When you open a website, there’s a good chance you’ll see a prompt requesting permission to display notifications. You might have also noticed that some websites load faster on subsequent visits due to caching. These are just a few examples of how building progressive web apps (PWAs) into your website can help enhance the user experience.

In this tutorial, we’ll show you how to implement notifications, caching, and messages in a PWA. We’ll cover the following.

  • Adding notifications to your page
  • Adding elements to the cache that you didn’t explicitly define in the service worker
  • The “message” event

If you’re not familiar with PWAs, I recommended starting here.

You can find the initial code for this project on GitHub.

Notifications

A typical request to allow notifications looks something like this:

Notification Request Prompt

Such requests, while sometimes annoying, can also be useful. For instance, enabling notifications would allow a user to navigate to other browser tabs while waiting for an event to happen on a website, such as receiving a message in a chat.

We see it everywhere nowadays, likely because it’s super easy to implement.

Start by requesting access. Create a file named notifications.js in the public/js folder and add it to your page.

public/index.html:

We made a custom demo for .
No really. Click here to check it out.

<html>
  <head>
    <link rel="manifest" href="/js/pwa.webmanifest">
    <link rel="apple-touch-icon" href="/images/apple-touch.png">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="theme-color" content="#764ABC"/>
  </head>
  <body>
    <div>
      <span>This example is for the article of progressive web apps written for LogRocket</span>
      <br>
      <span>You are now</span> <span><b class="page-status">online</b></span>
    </div>
    <script src="/js/notifications.js"></script>
    <script src="/js/pwa.js"></script>
    <script src="/js/status.js"></script>
  </body>
</html>

public/js/notifications.js:

document.addEventListener('DOMContentLoaded', init, false)
function init() {
  if ('Notification' in window) {
    Notification.requestPermission(result =>  {
      if (result === 'granted') {
        console.log('Acess granted! :)')
      } else if (result === 'denied') {
        console.log('Access denied :(')
      } else {
        console.log('Request ignored :/')
      }
    })
  } 
}

If the browser has notifications enabled, it will request permission to display notifications in the future when the user opens the same website, even if the tab is not selected or the browser is minimized.

While we’re requesting access to the user immediately after entering the page, the recommended practice is to request access only after a user interaction, such as the press of a button.

Web Browser Notification Prompt

If the user selects “Block,” they can always allow notifications later by clicking to the left of the URL and selecting either “Ask” or “Allow” — at least, that’s how it behaves in Chrome.

Web Browser Notification Options Dropdown

Assuming that the notification is accepted, nothing will show. That’s because we didn’t call the function to trigger a notification.

Create a new route in the server called /notification to return the current time of the server. We’ll also request the front end to that route every second so we can see it working even if we minimize the browser or switch tabs.

Note: If you want live updates, you should use WebSockets instead of a setTimeout. This example is for simplicity’s sake.

server.js:

const express = require('express')
const path = require('path')
const fs = require('fs')
const https = require('https')

const httpPort = 80
const httpsPort = 443

const key = fs.readFileSync('./certs/localhost.key')
const cert = fs.readFileSync('./certs/localhost.crt')

const app = express()
const server = https.createServer({key: key, cert: cert }, app)

app.use((req, res, next) => {
  if (!req.secure) {
    return res.redirect('https://' + req.headers.host + req.url)
  }
  next()
})
app.use(express.static(path.join(__dirname, 'public')))

app.get('/', function(req, res) {
  res.sendFile(path.join(__dirname, 'public/index.html'))
})

app.get('/notification', function(req, res) {
  const date = new Date()
  const message = {
    date: date.toLocaleString()
  }
  res.send(message)
})

app.listen(httpPort, function () {
  console.log(`Listening on port ${httpPort}!`)
})

server.listen(httpsPort, function () {
  console.log(`Listening on port ${httpsPort}!`)
})

public/js/notifications.js:

document.addEventListener('DOMContentLoaded', init, false)

function init() {
  if ('Notification' in window) {
    Notification.requestPermission(result =>  {
      if (result === 'granted') {
        console.log('Acess granted! :)')
        showServerTimeNotification()
      } else if (result === 'denied') {
        console.log('Access denied :(')
      } else {
        console.log('Request ignored :/')
      }
    })
  }

  function showServerTimeNotification() {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.ready.then(registration => {
        setInterval(() => {
          fetch('/notification')
            .then(res => res.json())
            .then((response) => {
              const title = 'Server time'
              const options = {
                body: `Right now it's ${response.date}`,
              }
              registration.showNotification(title, options)
            })
        }, 1000)
      })
    }
  }
}

We should now have a notification now showing every second with the current time of the server, like so:

Google Chrome Notification Showing the Current Server Time

To get into more detail as far as the code, we must first check whether the service worker is working before proceeding to show the notification. You don’t need to be in the same file as the service worker since navigator.serviceWorker.ready is triggered once the status of the service worker changes.

What makes the service worker open is registration.showNotification(title, options), which is self-explanatory. It has many options so you can customize the look and behavior. For example, you can remove the sound and vibration in a phone with a silent, or add a custom icon or image, to name a few. The one we’re using is the body, which represents the message that shows up.

Head to MDN for a full list of options.

Cache

You might be wondering how you can cache files and requests without having to manually update them in the service-worker.js file.

Let’s use the notifications we just coded to demonstrate how to implement a cache system that:

- Returns the cached data if the request already exists
- Requests to update the current cache
- Executes the request and saves to the cache in case `no``-``cache` is found

This type of caching, where you’re constantly fetching the previous records, is known as stale-while-revalidate. This is useful for information that changes rarely, such as a list of countries or a user avatar image.

Let’s create a button that, when pressed, shows a notification of the last time the server was accessed and the cached data while simultaneously caching the request — regardless of whether it’s defined in the service-worker.js file.

This will work even if offline. Start by changing the notifications to only show when a button is pressed.

public/index.html:

<html>
  <head>
    <link rel="manifest" href="/js/pwa.webmanifest">
    <link rel="apple-touch-icon" href="/images/apple-touch.png">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="theme-color" content="#764ABC"/>
  </head>
  <body>
    <div>
      <span>This example is for the article of progressive web apps written for LogRocket</span>
      <br>
      <span>You are now</span> <span><b class="page-status">online</b></span>
    </div>
    <div>
      <button class="btn-notifications">Last notification date</button>
    </div>
    <script src="/js/notifications.js"></script>
    <script src="/js/pwa.js"></script>
    <script src="/js/status.js"></script>
  </body>
</html>

public/js/notifications.js

document.addEventListener('DOMContentLoaded', init, false)

function init() {
  const notificationsBtn = document.querySelector('.btn-notifications')

  notificationsBtn.addEventListener('click', () => {
    requestPermission()
  })

  function requestPermission() {
    if ('Notification' in window) {
      Notification.requestPermission(result =>  {
        if (result === 'granted') {
          console.log('Acess granted! :)')
          showServerTimeNotification()
        } else if (result === 'denied') {
          console.log('Access denied :(')
        } else {
          console.log('Request ignored :/')
        }
      })
    } else {
      alert('Your browser does not support notifications')
    }
  }

  function showServerTimeNotification() {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.ready.then(registration => {
        fetch('/notification')
          .then(res => res.json())
          .then((response) => {
            const title = 'Server time'
            const options = {
              body: `Last request: ${response.date}`,
            }
            registration.showNotification(title, options)
          })
      })
    }
  }
}

With this change, the users won’t get a bothersome request to enable notifications the moment they enter the website. This increases user conversion and makes users less likely to navigate away from your website.

However, this code is not enough since our goal is to use the cache to show the last saved date of the last request, not the current server date. For that, we’ll need to update the service worker to the following.

public/js/service-worker.js (this is only needed to update the fetch event).

// ...

self.addEventListener('fetch', function(event) {
  event.respondWith(async function() {
    const cache = await caches.open(CACHE_NAME)
    const cacheMatch = await cache.match(event.request)

    if (navigator.onLine) {
      const request = fetch(event.request)

      event.waitUntil(async function() {
        const response = await request
        await cache.put(event.request, await response.clone())
      }())

      return cacheMatch || request
    }

    return cacheMatch // this will be undefined when offline if there are no matches
  }())
})

// ...

Unlike the last example, now we’re checking whether the request we’re making is cached. If it isn’t, we make the usual request and return the response.

The cache is updated after every successful request because of the following section.

if (navigator.onLine) {
  const request = fetch(event.request)

  event.waitUntil(async function() {
    const response = await request
    await cache.put(event.request, await response.clone())
  }())

  return cacheMatch || request
}

return cacheMatch

This checks whether the browser is connected to the internet to avoid spamming a request that leads nowhere. The waitUntil is there to tell the service worker not to be replaced until a response has been found for the request. The clone is so we can read the response of the request again in case it was already read.

By pressing the button, we should now be caching our requests, even if they’re not on the list of requests to the cache.

Caching Notification Requests

Be careful with this type of implementation; you don’t want to fill the user’s browser cache. If you prefer the safer approach, you can still keep this type of cache and change the condition to update only if it’s present in the cache.

if (navigator.onLine && cacheMatch) {
  event.waitUntil(async function() {
    const response = await request
    await cache.put(event.request, await response.clone())
  }())
}

Message event

Lastly, let’s go over how to communicate with the service worker or make the service worker communicate with the client. This is useful if you need to call service worker methods like skipWaiting.

Client → Service Worker

To make the client communicate to the service worker, we’ll need to post a message from the client side and receive it with the message event on the service worker side.

public/js/notifications.js:

document.addEventListener('DOMContentLoaded', init, false)

function init() {
  const notificationsBtn = document.querySelector('.btn-notifications')

  navigator.serviceWorker.controller.postMessage('Hello service worker!')

  notificationsBtn.addEventListener('click', () => {
    requestPermission()
  })

  // ...
}

public/js/service-worker.js:

// ...

self.addEventListener('message', function(event) {
  console.log('Message received ->', event.data)
})

// ...

Service Worker → Client

There are multiple ways to do reverse communication. We’ll use the Clients interface that is available in the service worker to send a message back.

public/js/service-worker.js:

self.addEventListener('message', function(event) {
  console.log('Message received from client ->', event.data)

  self.clients.matchAll().then(clients => {
    clients.forEach(client => client.postMessage('Hello from SW!'));
  })
})

public/js/notifications.js:

document.addEventListener('DOMContentLoaded', init, false)

function init() {
  const notificationsBtn = document.querySelector('.btn-notifications')

  navigator.serviceWorker.controller.postMessage('Hello service worker!')

  navigator.serviceWorker.onmessage = (event) => {
    console.log('Message received from SW ->', event.data)
  }

  notificationsBtn.addEventListener('click', () => {
    requestPermission()
  })

  // ...
}

Compatibility

Most recent browsers — basically anything besides IE and iOS Safari — have support for the topics discussed in this article, but always be sure to check a tool such as MDN and Can I Use.

Conclusion

These kinds of notifications work if the website is open. Some notifications use the more recent Push API, which is still in the experimental phase and will work even if the browser is closed.

As for caching, stale-while-revalidate is just one of many examples. For example, we could have never hit the server again, decided to update the cache after a certain period of time, or updated only on a specific event. The most appropriate method depends on the scenario.

Communication between the service worker and the client can also be achieved with the Broadcast Channel API or MessageChannel interface.

You can reference the final code of this tutorial on GitHub.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Diogo Spínola I'm a learning enthusiast, web engineer, and blogger who writes about programming stuff that draws my attention.

Leave a Reply