PWA
For more resources on PWA, go to these sites:
JavaScript in the background
Lifecycle of a web page
In the past, web pages would consume resources infinitely, awake all the time. Now they have a strict lifecycle that they adhere to, where the browsers can cut off resources to the website without the developer knowing. You can hook into these events to prevent your application from getting ruined by the browser unexpectedly discarding your website's memory.
A web app goes to the background when its tab is either minimized, covered, switched, or closed.
The behavior of apps executing in a background state differs between browsers:
- chrome: Kills all main thread activity. Timers set in main thread like
setTimeout()
orsetInterval()
will continue to execute, but at a much lower frequency. But service workers and worker threads continue to function in the background at 100% capacity. - safari: Kills all activity, including service workers, timers set in main thread, and worker threads.
Different browsers claiming to save battery are just in reality killing all service worker and worker threads when a web app goes into the background, reducing CPU execution.
Mobile behavior
Even if you have dozens of apps open, there is only ever one active app - the app you are currently using or are focused on.
Every other app is put in a suspended state, where none of their code is executing.
- ios: Immediately kills all activity in a suspended web app, including service worker and worker threads
- android: Waits 5 minutes before killing activity in a suspended web app. Service workers run perpetually
Lifecycle on web
The window
object in javascript can listen to these events to detect changes in the web app’s lifecycle, like when it’s memory is about to get discarded
"load"
: app was first loaded"visibilitychange"
: the app changes from background state to active foreground state or goes from active to background"freeze"
: after 5 minutes of being in the background, chrome freezes and suspends the web app execution"resume"
: if the user goes back to the app before 5 minutes are up before thefreeze
event triggers, then the app is back in memory and resumes execution. Also triggersvisibilitychange
"beforeunload"
: if the user tries to exit the app. Use this to ask the user to save their data
A page can fall into these states:
- active: The page is focused and visible
- passive: The page is visible, but not focused
- hidden: The page is not visible and not focused
- frozen: All javascript long-running processes like timers and intervals have been stopped on the page
- terminated: All the processes on the webpage have been killed and the memory has been discarded.
- discarded: The browser unloads the web app. When doing something that takes up a lot of memory, like recording a video on your phone, the OS can suspend any PWA context and completely discard it. You can check if your PWA was discarded with
document.wasDiscarded
. Then theload
event will be triggered
WARNING
Do your cleanup sooner rather than later The transition to hidden is also often the last state change that's reliably observable by developers. Your app might already be discarded by the time it gets to frozen or terminated, so do all your cleanup and data saving when the app goes in the background into the hidden state.
Detecting visibility changes
document.visibilityState
: returns a string giving information on whether the web app is in the foreground (active, passive) or in the background (hidden). You have two possible values."hidden"
: in the background"visible"
: in the foreground
document.hasFocus()
: returns whether or not the page is in the active state.document.hidden
: whether or not the document is hidden
const getState = () => {
if (document.visibilityState === 'hidden') {
return 'hidden';
}
if (document.hasFocus()) {
return 'active';
}
return 'passive';
};
// launches whenever the app changes visiblity
document.addEventListener('visibilitychange', event => {
if (document.visibilityState = 'hidden') {
// We are in the background
// on some devices, last chance to save current state
} else {
// We are back in the foreground
}
});
We can detect when a tab is about to frozen and gets its activity suspended by listening to the freeze
event on the window.
window.addEventListener("freeze", (e) => {
// when app is about to be frozen, do stuff like save data
})
Before unload
The beforeunload
event on the window is resource intensive, so make sure to only conditionally register it, like so:
const beforeUnloadListener = (event) => {
event.preventDefault();
// Legacy support for older browsers.
return (event.returnValue = true);
};
// A function that invokes a callback when the page has unsaved changes.
onPageHasUnsavedChanges(() => {
window.addEventListener('beforeunload', beforeUnloadListener);
});
// A function that invokes a callback when the page's unsaved changes are resolved.
onAllChangesSaved(() => {
window.removeEventListener('beforeunload', beforeUnloadListener);
});
Picture in picture
Basics
The picture-in-picture API is a simple API that allows you to put any HTML video DOM element in picture in picture mode.
document.pictureInPictureElement
: returns the current element in your page that is in picture-in-picture mode.null
if nothing.document.exitPictureInPicture()
: makes whatever video is playing picture in picture to exit it.videoEl.requestPictureInPicture()
: makes any video element play picture in picture.
document.getElementById("btnPiP").addEventListener("click", (event) => {
// if we currently have a video playing in picture-in-picture, exit picture-in-picture
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else {
// otherwise, let's request picture-in-picture
document.querySelector("video").requestPictureInPicture();
}
});
There are also two events on the <video>
element you can listen for concerning picture-in-picture events:
"enterpictureinpicture"
: video enters pip mode"leavepictureinpicture"
: video exits pip mode
Making any element picture in picture
First step is to create the custom PIP element, which is the div with the id playerContainer
.
<div id="playerContainer">
<div id="player">
<video id="video"></video>
</div>
</div>
<button id="pipButton">Open Picture-in-Picture window</button>
Then we bind an event listener to our open PIP button to request picture in picture.
pipButton.addEventListener('click', async () => {
const player = document.querySelector("#player");
// Open a Picture-in-Picture window.
const pipWindow = await documentPictureInPicture.requestWindow();
// Move the player to the Picture-in-Picture window.
pipWindow.document.body.append(player);
});
You can pass in an object of options to the documentPictureInPicture.requestWindow()
method, and pass in the width
and height
properties to control the size of the PIP window that appears. Here are all the possible properties
width
: width of the PIP windowheight
: height of the PIP windowdisallowReturnToOpener
: if true, hides the "back to tab" button
const player = document.querySelector("#player");
// Open a Picture-in-Picture window whose size is
// the same as the player's.
const pipWindow = await documentPictureInPicture.requestWindow({
width: player.clientWidth,
height: player.clientHeight,
disallowReturnToOpener: true,
});
Dealing with closing the player
To deal with closing the player, you have to return the element back to its original position ont he page. You do it by listening to the "pagehide"
event on the window object.
pipButton.addEventListener("click", async () => {
const player = document.querySelector("#player");
// 1. Open a Picture-in-Picture window.
const pipWindow = await documentPictureInPicture.requestWindow();
// 2. Move the custom element to the Picture-in-Picture window.
pipWindow.document.body.append(player);
// 3. Move the custom element back when the Picture-in-Picture window closes.
pipWindow.addEventListener("pagehide", (event) => {
const playerContainer = document.querySelector("#playerContainer");
const pipPlayer = event.target.querySelector("#player");
playerContainer.append(pipPlayer);
});
});
You can close the player programmatically using the pipWindow.close()
method.
pip window
We can get a picture in picture window by executing the async documentPictureInPicture.requestWindow()
method, which returns that PIP window instance. However, there are multiple ways of retrieving it:
// 1. documentPictureInPicture.requestWindow()
async function getNewPIPWindow() {
return await documentPictureInPicture.requestWindow();
}
// 2. event listener
documentPictureInPicture.addEventListener("enter", (event) => {
const pipWindow = event.window;
});
// 3. property
const pipWindow = documentPictureInPicture.window;
once you have the pipWindow
object, you can just treat it as a mini-instance of the window
object. You can add DOM elements to it, remove, add event listeners, etc.
Other use cases
Focusing back on the webpage
Use the window.focus()
method to focus the opener window from the Picture-in-Picture window. This method requires a user gesture.
const returnToTabButton = pipWindow.document.createElement("button");
returnToTabButton.textContent = "Return to opener tab";
returnToTabButton.addEventListener("click", () => {
window.focus();
});
pipWindow.document.body.append(returnToTabButton);
styling in picture and picture mode
Use this media query to conditionally style picture in picture elements.
@media all and (display-mode: picture-in-picture) {
body {
margin: 0;
}
h1 {
font-size: 0.8em;
}
}
Service worker
The basics
You can think of service workers as a middleware for your app. They intercept every web request the app makes, which allows you to do things like caching requests, assets, and much more.
Service workers typically have a lifetime of 40 seconds where they are running, and then go back to sleep until activated by another event.
Register a service worker like so:
<script>
navigator.serviceWorker.register('sw.js')
</script>
scope
A service worker has a scope, meaning the routes and files it can access of the web app. If you register the service worker at the root of your app at the /
route, it’ll have access to everything in the web app.
You can also register service workers at different url paths for a more narrow scope, like a service worker that can only access things under the /dog
path.
- There is only one service worker per scope
- Typically we have one service worker per PWA.
register behavior
By default, PWAs will use the first service worker version that is successfully registered, kind of like caching the service worker. To get rid of this behavior in development, go to devtools, and click on update on reload to update and re-register the service worker each time you reload the page.
api
self
global variable refers to the service worker, which has a different API than the browser because service workers don’t run in the browser.
We can then listen to service worker specific events with self.addEventListener()
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="webworker" />
const sw = self as unknown as ServiceWorkerGlobalScope;
sw.addEventListener("install", (event) => {});
Storage
With service workers, you will be mainly focusing on caching assets. You have two types of storage to help accomplish an offline experience:
- caches API: used as a key-value pair of Request-Response items. Can be used from the service worker and frontend.
- indexedDB: A large-asynchronous storage that can store any type of JavaScript object. Can be used from the service worker and frontend.
You cannot use browser-only storage techniques like LocalStorage.
an event driven architecture
Much like background scripts in manifest v3 for chrome extensions, service workers are not long-lived and are rather event driven.
This means that any code you have in global scope will be offloaded and is ephemeral. Rather, put all your code inside the event listeners.
Service worker clients
1. What are "Clients"?
Clients are the windows, tabs, or web workers that your service worker controls. Each client represents an active instance of your web app.
A Client
instance represents a generic client, while a WindowClient
instance represents a browser tab that runs your web app, and exposes additional methods.
2. service worker clients
You can access all clients belonging to the current service worker through the self.clients
property. It's not an array of clients, but it's an object for querying clients:
self.clients.matchAll(options?)
- Returns an array of clients that matches the query specifications. Here are the keys of the option object you can pass in:type
: the type of client you want to query. Use'window'
to only get backWindowClient
instances.includeUncontrolled
: a boolean that if true, will also include all clients that do not have the current service worker controlling it yet.
self.clients.get(id)
- Gets a specific client by its client IDself.clients.openWindow(url)
- Opens a new window/tabself.clients.claim()
- Takes control of uncontrolled clients, updates the service worker on those clients (used in the "activate" event).
async function getWindowClients() : WindowClient[] {
return await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
});
}
async function getAllClients(): Client[] {
return await self.clients.matchAll()
}
3. Client Object Properties:
These are the properties on a generic Client
client.id
- Unique identifierclient.url
- Current URL of the clientclient.focused
- Whether the client has focusclient.visibilityState
- 'visible', 'hidden', or 'prerender'client.frameType
- 'auxiliary', 'top-level', 'nested'
4. Client methods
These are the methods on a generic Client
client.postMessage(data)
- Sends message to the client. See more in the messaging section
5. wiNDOW Client Methods:
client.focus()
- Brings the client to foregroundclient.navigate(url)
- Navigates the client to a new URL
Service worker lifecycle
The service worker lifecycle follows three stages:
- Registration: occurs when the client side page registers the service worker
- Installation: occurs when the service worker is first installed. Fires the
"install"
event.- It can use this for pre-caching resources (e.g., populate cache with long-lived resources like logos or offline pages).
- Activation: occurs when the service worker has finished installing. Fires the
"activate"
event.- This service worker can now do clean up actions (e.g., remove old caches from prior version) and ready itself to handle functional events. If there is an old service worker in play, you can use
self.clients.claim()
to immediately replace the old service worker with your new one.
- This service worker can now do clean up actions (e.g., remove old caches from prior version) and ready itself to handle functional events. If there is an old service worker in play, you can use
You also have several other events for service workers:
"fetch"
: This event is triggered each time the website requests a resource over the internet, like HTML, CSS, JS, fonts, images, etc.- The most common use case here is to implement a caching strategy for resources
"message"
: This event is triggered when the frontend sends a message to the service worker. Use this event for inter-process communication."activate"
: when the new service worker gets activated"install"
: when the new service worker gets installed for the first time
registering a service worker
You register a service worker with the navigator.serviceWorker.register(url)
method, which returns a service worker registration object.
const swRegistration = await navigator.serviceWorker.register('/sw.js')
There can only ever be one active service worker, which leads us into the lifecycle:
Once you register a service worker, it can be in one of three states:
- installing: the service worker is installing for the first time, fetched by the
swRegistration.installing
property. - waiting: the service worker is waiting for the old service worker to step down, fetched by the
swRegistration.waiting
property. - active: the service worker is active and completely installed, fetched by the
swRegistration.active
property.
Here are methods to deal with registration lifecycle on the client side:
export class PWAModel {
static async registerWorker(url: string) {
return await navigator.serviceWorker.register(url, {
type: "module",
scope: "/",
});
}
static async getCurrentWorker() {
return await navigator.serviceWorker.ready;
}
static async onWorkerChange(cb: (worker: ServiceWorker) => void) {
navigator.serviceWorker.addEventListener("controllerchange", (event) => {
cb(event.target as ServiceWorker);
});
}
}
updating service workers
Service workers won't update to their newest version by default, so it's important to add this code during development to make sure the service worker always updates itself.
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
When you're updating a service worker, you'll also often want to delete old caches or at least update the cache. There's more info in the caching section below:
const cachename = "v1"
sw.addEventListener("activate", (event) => {
async function cleanCache() {
// gets all cache store names
const keys = await caches.keys();
keys
.filter((key) => key !== cachename)
.forEach(async (key) => {
// deletes cache store with the specified name
await caches.delete(key);
});
}
async function updateServiceWorker() {
sw.clients.claim();
}
async function onActivate() {
await cleanCache()
await updateServiceWorker()
}
event.waitUntil(onActivate());
});
Caching
We use the caches
Web API to cache HTTP responses and return them during the "fetch"
event of the service worker.
There are many different caching strategies we can employ:
- precaching: Caching static assets prior to needing them, like HTML, CSS, and JS
- cache first: Always reading from the cache first for resources, and if not a cache hit, we fetch the resource and then add it to the cache.
- stale while revalidate: Always read from the cache first for resources, but perform a network request and update the cache in the background.
Cache API
cache storage is a key value store where the keys are requests and the values are web responses. We use this api extensively to cache requests in the service worker.
You can open a cache store by calling caches.open(cacheName)
, passing in any name you want for the cache store.
This is a completely async API.
caches.open(cacheName : string)
: opens and returns a cache store. Creates the cache store if it doesn’t exist. Returns acache
object.caches.match(resource)
: returns a web response of the cached resource, if cached.undefined
otherwise.cache.add(resource)
: adds a resource to the cache. A resource can be a network resource or a local file.cache.addAll(resources: string[])
: adds all resources from the list of resources to the cache.cache.put(resource, response)
: updates the cache by storing the passed in web response with the associated resource.cache.keys()
: returns all the keys (Requests) from the cache store.
Instead of storing Requests, you can store resources (local and network), which will be converted to requests automatically and stored.
cache.add("/styles.css")
cache.add(new Request("/styles.css"))
adding to cache
Adding to cache is a 2 step process:
- Create a cache store using
caches.open(cacheName)
, which returns a cache object. The cache name can be anything, but make to prefix it to avoid conflicts with cache stores from other apps. - Add to the cache using
cache.add(filename)
, which adds the specified file or online resource to the website cache.
export async function cacheImage(
filename: string,
cacheName: string = "coffeemasters-images"
) {
const cache = await caches.open(cacheName);
await cache.add(filename);
}
App shell caching: Precaching
A common pattern is to fetch and cache critical resources for offline usage during installation, like HTML, CSS, and JS, so that the app works offline. You also have to cache network assets you use in your app, like fonts, external scripts, and external CSS like tailwind.
We listen to the "install"
event on the service worker, and then add critical resources to the cache.
WARNING
A common caching mistake
If you notice below, all our app shell resources start with a /
. That is because we need to cache URLs, not files. So you would refer to index.html
as /
instead.
sw.addEventListener("install", (event) => {
const criticalResources = [
"/",
"/styles.css",
"/app.js",
"/icons/icon-512.png",
"/icons/icon-1024.png",
"https://fonts.gstatic.com/s/materialicons/v67/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2",
];
const cacheAppShell = async () => {
const appShellCache = await caches.open("pwa-app-shell");
await appShellCache.addAll(criticalResources);
};
event.waitUntil(cacheAppShell());
});
If it seems insane to try and think of all the URLs to cache, but luckily in your service worker you can just ping your hosting server through fetch()
to query the filesystem and find all necessary items to cache, then return that.
Cache-first
The cache-first strategy follows these steps:
- Fetch from the cache
- If a cache-hit, return the response
- If a cache-miss, return a network request. Do not update the cache.
self.addEventListener('fetch', event => {
event.respondWith(async () => {
const cache = await caches.open(CACHE_NAME);
// match the request to our cache
const cachedResponse = await cache.match(event.request);
// check if we got a valid response
if (cachedResponse !== undefined) {
// Cache hit, return the resource
return cachedResponse;
} else {
// Otherwise, go to the network
return fetch(event.request)
};
});
});
When going to the cache constantly, the data will never be updated unless the service worker itself gets updated.
WARNING
Stale Data The downside of a cache-first approach is that you will always have stale data since you never go to the network to fetch fresh data. A stale while revalidate approach is much better.
Stale while revalidate
- Fetch from the cache
- If a cache-hit, make a network request, update the cache, and return the previous cache value. If the network request fails, just return the previous cache value without updating it.
- If a cache-miss, make a network request, update the cache, and return the network request.
sw.addEventListener("fetch", (event) => {
async function handleCacheNetworkFirst() {
try {
// 1. make network request to asset and get back response
const res = await fetch(event.request);
// 2. open cache
const cache = await caches.open("coffee-images");
// 3. clone response and cache it
// responses are streams. We must clone them.
cache.put(event.request, res.clone());
return res;
}
catch (error) {
// 4. if network fails, try to get from cache
const res = await caches.match(event.request);
return res!;
}
}
// 5. Return response
event.respondWith(handleCacheNetworkFirst());
});
Network first
- Fetch from the network
- Update the cache
- If the network request from step 1 failed, return the response from the cache.
async function networkFirst(request: Request, cacheStorage: CacheStorageModel) {
try {
// 1. fetch from network
const response = await fetch(request);
if (!response.ok) {
throw new Error("Network response was not ok");
}
// 2. udpate cache
await cacheStorage.put(request, response.clone());
// 3. return network response
return response;
} catch (e) {
// 1a) if network fails, return from cache
const cacheResponse = await cacheStorage.match(request);
if (cacheResponse) {
return cacheResponse;
}
throw e;
}
}
Advanced caching architecture + clearing cache
Advanced caching requires also knowing when to invalidate the cache intentionally. You can do this by smartly naming your caches and putting different types of resources into different cache stores.
NOTE
Also use semantic versioning to make invalidating previous caches easier so you can free up space.
Step 1: create cache names
const APP_SHELL = ["/", "/styles.css", "/frontend/index.js"];
const version = "v1:";
const sw_caches = {
appShell: {
name: `${version}appShell`,
keys: new Set<string>(APP_SHELL),
},
app: {
name: `${version}app`,
keys: new Set<string>(),
},
};
Step 2: Invalidate old cache versions in "activate" event
The "activate"
event is activated when the previous service worker is relieved of its post and is not using resources anymore.
NOTE
It would be a bad idea to try and clear caches in the "install"
event when the previous worker is still active, so it's better to do it now in the "activate"
event
// Update service worker
sw.addEventListener("activate", (event) => {
// delete all cache stores with previous versions
async function cleanCache() {
// gets all cache store names
const keys = await caches.keys();
keys
.filter((key) => {
return !key.startsWith(version);
})
.forEach(async (key) => {
// deletes cache store with the specified name
await caches.delete(key);
});
}
async function updateServiceWorker() {
sw.clients.claim();
}
event.waitUntil(
(async () => {
await cleanCache();
await updateServiceWorker();
})()
);
});
Step 3: selectively cache based on resource type
Instead of updating the cache for our app all at once, we selectively update the cache based on the request url that is coming in.
sw.addEventListener("fetch", (event) => {
async function cache() {
if (sw_caches.appShell.keys.has(event.request.url)) {
return CacheStrategist.cacheFirst(
event.request,
sw_caches.appShell.name
);
} else {
sw_caches.app.keys.add(event.request.url);
return CacheStrategist.staleWhileRevalidate(
event.request,
sw_caches.app.name
);
}
}
if (event.request.url.startsWith("http")) {
event.respondWith(cache());
}
});
Service worker development
SW boilerplate
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="webworker" />
import { CacheStrategist } from "./backend/CacheStorageModel";
const sw = self as unknown as ServiceWorkerGlobalScope;
const APP_SHELL = ["/", "/styles.css", "/frontend/index.js"];
const APP_CACHE_NAME = "app-v1";
// Implement app shell caching
sw.addEventListener("install", (event) => {
console.log("Service Worker installed");
event.waitUntil(CacheStrategist.cacheAppShell(APP_CACHE_NAME, APP_SHELL));
});
// Update service worker
sw.addEventListener("activate", (event) => {
event.waitUntil(sw.clients.claim());
});
sw.addEventListener("fetch", (event) => {
// these are invalid protocols for service workers
if (
event.request.url.startsWith("chrome") ||
event.request.url.startsWith("chrome-extension")
) {
return;
}
event.respondWith(
CacheStrategist.staleWhileRevalidate(event.request, APP_CACHE_NAME)
);
});
Here's a more in depth boilerplate:
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="webworker" />
import { CacheStrategist } from "./sw-utils/CacheManager";
const sw = self as unknown as ServiceWorkerGlobalScope;
const APP_SHELL = ["/"];
// * change this to update the cache
const version = "v1";
const APP_CACHE_NAME = `app-${version}`;
const cacheManager = new CacheStrategist(APP_CACHE_NAME);
sw.addEventListener("install", (event) => {
const cacheAppShell = async () => {
try {
await cacheManager.cacheAppShell(APP_SHELL);
} catch (e) {
console.error(e);
console.log("failed to cache app shell");
}
await sw.skipWaiting();
};
event.waitUntil(cacheAppShell());
});
sw.addEventListener("fetch", (event) => {
function isValidCacheableRequest(request: Request) {
const booleansThatRepresentInvalidURLStates = [
event.request.url.startsWith("chrome"),
event.request.url.startsWith("chrome-extension"),
];
return (
request.method === "GET" &&
booleansThatRepresentInvalidURLStates.every((b) => !b)
);
}
// only cache GET requests that are not chrome or chrome-extension
if (isValidCacheableRequest(event.request)) {
event.respondWith(cacheManager.staleWhileRevalidate(event.request));
}
});
sw.addEventListener("activate", (event) => {
const activate = async () => {
const deleted = await cacheManager.cacheStorage.deleteOldCaches();
await sw.clients.claim();
};
event.waitUntil(activate());
});
Custom cache class
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/**
* A class for storing cache data around a single cache name.
*/
export class CacheStorageModel {
private cache: Cache | null = null;
constructor(public readonly cacheName: string) {}
private async openCache() {
const cache = await caches.open(this.cacheName);
return cache;
}
async deleteOldCaches() {
const cacheNames = await caches.keys();
const oldCaches = cacheNames.filter((name) => name !== this.cacheName);
return await Promise.all(oldCaches.map((name) => caches.delete(name)));
}
async addAll(requests: string[]) {
if (!this.cache) {
this.cache = await this.openCache();
}
await this.cache.addAll(requests);
}
async match(request: Request) {
if (!this.cache) {
this.cache = await this.openCache();
}
return this.cache.match(request);
}
async matchAll(request: Request) {
if (!this.cache) {
this.cache = await this.openCache();
}
return this.cache.matchAll(request);
}
async add(request: Request) {
if (!this.cache) {
this.cache = await this.openCache();
}
return this.cache.add(request);
}
async delete(request: Request) {
if (!this.cache) {
this.cache = await this.openCache();
}
return this.cache.delete(request);
}
async deleteMatching(predicate: (request: Request) => boolean) {
if (!this.cache) {
this.cache = await this.openCache();
}
const allKeys = await this.cache.keys();
const keysToDelete = allKeys.filter(predicate);
return await Promise.all(
keysToDelete.map((key) => this.cache!.delete(key))
);
}
async keys(request: Request) {
if (!this.cache) {
this.cache = await this.openCache();
}
return this.cache.keys(request);
}
async put(request: Request, response: Response) {
if (!this.cache) {
this.cache = await this.openCache();
}
return this.cache.put(request, response);
}
}
/**
* A class for implementing caching strategies with service workers
*/
export class CacheStrategist {
public cacheStorage: CacheStorageModel;
constructor(public readonly cacheName: string) {
this.cacheStorage = new CacheStorageModel(cacheName);
}
async getOfflinePage(url: string) {
const response = await this.cacheStorage.match(new Request(url));
if (response) {
return response;
}
return fetch(url);
}
async cacheFirst(request: Request) {
return CacheStrategist.cacheFirst(request, this.cacheStorage);
}
async staleWhileRevalidate(request: Request) {
return CacheStrategist.staleWhileRevalidate(request, this.cacheStorage);
}
async cacheAppShell(appShell: string[]) {
return CacheStrategist.cacheAppShell(this.cacheStorage, appShell);
}
static async cacheAll(cacheStorage: CacheStorageModel, requests: string[]) {
await cacheStorage.addAll(requests);
}
static async cacheAppShell(
cacheStorage: CacheStorageModel,
appShell: string[]
) {
await cacheStorage.addAll(appShell);
}
static async cacheFirst(request: Request, cacheStorage: CacheStorageModel) {
// 1. go to cache
const cacheResponse = await cacheStorage.match(request);
// 2. if cache-hit, return response
if (cacheResponse) {
return cacheResponse;
}
// 3. if cache-miss, go to network and update cache
else {
const response = await fetch(request);
await cacheStorage.put(request, response.clone());
return response;
}
}
static async networkFirst(request: Request, cacheStorage: CacheStorageModel) {
try {
const response = await fetch(request);
if (!response.ok) {
throw new Error("Network response was not ok");
}
await cacheStorage.put(request, response.clone());
return response;
} catch (e) {
const cacheResponse = await cacheStorage.match(request);
if (cacheResponse) {
return cacheResponse;
}
throw e;
}
}
static async staleWhileRevalidate(
request: Request,
cacheStorage: CacheStorageModel
) {
const cacheResponse = await cacheStorage.match(request);
// 1. go to cache
// 2. if cache-hit, update cache in the background and return cache response
if (cacheResponse) {
try {
fetch(request).then((response) => {
cacheStorage.put(request, response.clone());
});
} catch (e) {
} finally {
// 2a. return cache response
return cacheResponse;
}
}
// 3. if cache-miss, go to network and update cache
else {
const response = await fetch(request);
if (!response.ok) {
throw new Error("Network response was not ok");
}
await cacheStorage.put(request, response.clone());
return response;
}
}
}
Messaging utilities
You can use this messaging class as a wrapper around creating structured messages. This makes it so that both the client and the service worker will have type safety when messaging each other:
export class MessageSystem<T extends Record<string, any>> {
getDispatchMessage<K extends keyof T>(key: K, payload: T[K]) {
return {
type: key,
payload,
};
}
messageIsOfType<K extends keyof T>(
key: K,
message: any
): message is {
type: K;
payload: T[K];
} {
if (!message || !message.type) {
return false;
}
return message.type === key;
}
getPayload<K extends keyof T>(key: K, message: any) {
if (!message || !message.type) {
return null;
}
return message.payload as T[K];
}
}
You would then export a message system specific to your app:
export const appMessageSystem = new MessageSystem<{
ping: {
ping: string;
};
}>();
Custom Service Worker Class (Client)
export class PWAServiceWorkerClient {
static async registerWorker(url: string) {
return await navigator.serviceWorker.register(url, {
type: "module",
});
}
static async getCurrentWorker() {
return await navigator.serviceWorker.ready;
}
static async onWorkerChange(cb: (worker: ServiceWorker) => void) {
navigator.serviceWorker.addEventListener("controllerchange", (event) => {
cb(event.target as ServiceWorker);
});
}
static postMessage(message: any) {
navigator.serviceWorker.controller?.postMessage(message);
}
}
Custom Service Worker Class
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="webworker" />
const sw = self as unknown as ServiceWorkerGlobalScope;
export class ServiceWorkerModel {
static onInstall(onInstall: (event: ExtendableEvent) => Promise<void>) {
sw.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
await onInstall(event);
await sw.skipWaiting();
})()
);
});
}
static onFetch(onRequest: (req: Request) => Promise<Response>) {
sw.addEventListener("fetch", (event) => {
event.respondWith(onRequest(event.request));
});
}
static onActivate(onActivate: (event: ExtendableEvent) => Promise<void>) {
sw.addEventListener("activate", (event) => {
const activate = async () => {
await onActivate(event);
await sw.clients.claim();
};
event.waitUntil(activate());
});
}
static onMessage(
onMessage: (event: ExtendableMessageEvent) => Promise<void>
) {
sw.addEventListener("message", (event) => {
event.waitUntil(onMessage(event));
});
}
static async notifyClients(cb: (client: WindowClient) => any) {
const clients = await sw.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
clients.forEach((client) => {
const message = cb(client);
client.postMessage(message);
});
}
static isValidCacheableRequest(
request: Request,
predicates?: ((request: Request) => boolean)[]
) {
const booleansThatRepresentInvalidURLStates = [
request.url.startsWith("chrome"),
request.url.startsWith("chrome-extension"),
];
return (
request.method === "GET" &&
booleansThatRepresentInvalidURLStates.every((b) => !b) &&
(predicates?.every((p) => p(request)) ?? true)
);
}
}
Here's an example that uses everything together:
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="webworker" />
import { appMessageSystem } from "./frontend/messageHelpers";
import { CacheStrategist } from "./sw-utils/CacheManager";
import { ServiceWorkerModel } from "./sw-utils/ServiceWorkerModel";
const sw = self as unknown as ServiceWorkerGlobalScope;
const APP_SHELL = ["/", "/offline.html"];
// * change this to update the cache
const version = "v2";
const APP_CACHE_NAME = `app-${version}`;
const cacheManager = new CacheStrategist(APP_CACHE_NAME);
ServiceWorkerModel.onInstall(async (event) => {
await cacheManager.cacheAppShell(APP_SHELL);
await sw.skipWaiting();
});
ServiceWorkerModel.onFetch(async (request) => {
if (ServiceWorkerModel.isValidCacheableRequest(request)) {
try {
console.log("trying to fetch", request.url);
const response = cacheManager.staleWhileRevalidate(request);
// only delete js chunks if online
if (navigator.onLine) {
cacheManager.cacheStorage.deleteMatching((r) => {
// always delete js chunks
return r.url.includes("_bun") && r.url.includes(".js");
});
}
return response;
} catch (error) {
console.error("No network and cache miss!", error);
return cacheManager.getOfflinePage("/offline.html");
}
}
return fetch(request);
});
ServiceWorkerModel.onActivate(async (event) => {
const deleted = await cacheManager.cacheStorage.deleteOldCaches();
});
ServiceWorkerModel.onMessage(async (event) => {
if (appMessageSystem.messageIsOfType("ping", event.data)) {
console.log("ping received", event.data.payload.ping);
}
});
Building on the server
When you are rolling out your own server, there are some things to keep in mind on how to host service workers:
- You must serve all assets statically on your server, including the service worker JS
import { $ } from "bun";
import { join } from "path";
import html from "./frontend/index.html";
import offline from "./frontend/offline.html";
await $`rm -rf ${join(__dirname, "dist")}`;
await Bun.build({
entrypoints: [join(__dirname, "sw.ts")],
outdir: join(__dirname, "dist"),
target: "browser",
format: "esm",
sourcemap: "inline",
minify: false,
naming: "[name].[ext]",
throw: true,
});
const server = Bun.serve({
port: 3000,
routes: {
"/": html,
"/sw.js": (req) => {
return new Response(Bun.file(join(__dirname, "dist", "sw.js")));
},
"/offline.html": offline,
},
development: true,
});
console.log(`Server is running on ${server.url}`);
Beacon API
The navigator.sendBeacon(url, data)
method is a way to just send a mutation request to the server without waiting back for a response. The beacon is always guaranteed to be sent as soon as the network connection is stable, so this API is robust to connectivity changes.
However, the weird thing is that you can't send standard Request
objects, so there is no way to attach headers. To attach content-typoe specific data, you just need to send a Blob
instance and set the appropriate content type:
class Beacon {
static sendJSON(url: string, data: any) {
navigator.sendBeacon(
url,
this.toBlob(JSON.stringify(data), "application/json")
);
}
static sendBlob(url: string, data: Blob) {
navigator.sendBeacon(url, data);
}
private static toBlob(data: any, mimeType: string) {
return new Blob([data], { type: mimeType });
}
}
Background sync
Background sync is a way for service workers to resume doing something once the user has internet connectivity again, like syncing local changes to the cloud.
- The frontend registers a background sync event with a specific name
async function registerSyncEvent() {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-event");
}
- The service worker should listen for the
"sync"
event. Based on the event name you can get throughevent.tag
, you can provide different methods to execute.
async function syncOperation() {
// send data to server here ...
console.log("sent to server!")
}
sw.addEventListener("sync", (e) => {
if (e.tag === "sync-event") {
e.waitUntil(syncOperation())
}
})
You should wrap your sync operation in a e.waitUntil()
because you don't want to shut down the service worker before it executes.
Background Fetch
The background fetch API is a way to send files and web requests to the service worker and have them do things with those files and requests.
Even when the web app is closed, these file and fetch transactions will go through.
-
Get the currently registered service worker with this code:
const registration = await navigator.serviceWorker.ready
-
Call on the background fetch API and fetch the URLs you want to send to the service worker, following this:
await registration.backgroundFetch.fetch(fetchName, urlsArray, metadata)
fetchName
: a name to give to this background fetchurlsArray
: an array of urls to fetch and send the requests to the service worker. These coudl be filesmetadata
: an object that controls how the file donwload dialog UI looks
-
Listen for the
backgroundfetchsuccess
event on the service worker to access the fetched URLs and filessw.addEventListener("backgroundfetchsuccess", async (event) => {
const downloadedFiles = await event.registration.matchAll();
// files is an array of objects, each with .request property
});
Here's a complete example of the client sending a background fetch request to the service worker.
const currentlyRegisteredServiceWorker = await navigator.serviceWorker.ready;
await currentlyRegisteredServiceWorker.backgroundFetch.fetch(
"media files",
["/media/audio.mp3", "/media/video.mp4"],
{
title: "Media Files",
icons: [
{
sizes: "800x800",
src: "/media/thumb.png",
type: "image/png",
},
],
}
);
Events
These are the events living on the service worker that you can listen to, related to background fetch:
sw.addEventListener("backgroundfetchsuccess", async (event) => {
// 1. get back array of files with this method
const downloadedFiles = await event.registration.matchAll();
// 2. Access the Request instance on each file
const downloadedRequests = downloadedFiles.map(
(downloadedFile) => downloadedFile.request
) as Request[];
});
sw.addEventListener("backgroundfetchclick", async (event) => {
console.log("clicked on file download UI dialog");
});
sw.addEventListener("backgroundfetchfailure", async (event) => {
console.log("download failed");
});
backgroundfetchsuccess
: triggered when the background fetch was a success. You can access the downloaded files herebackgroundfetchclick
: triggered when the user clicked on file download UI dialogbackgroundfetchfailure
: triggered when background fetch failed
Messaging
Frontend to service worker
The frontend can send messages to the service worker to do stuff.
From the frontend, use the navigator.serviceWorker.controller.postMessage()
method and pass some kind of object in.
navigator.serviceWorker.controller.postMessage({
type: `IS_OFFLINE`
// add more properties if needed
});
You can then listen for the message on the service worker through the "message"
event.
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'IS_OFFLINE') {
// take relevant actions
}
});
Service worker to frontend
Since a single service worker oversees all instances of a page, we send messages only to one service worker and it can send messages to multiple clients at a time.
Here's how we can fetch all clients or just specific clients and then send messages to those clients:
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'CACHE_UPDATED',
url: event.request.url
})
})
- Fetch a client using the
self.clients.get()
method
const client = await self.clients.get(event.clientId);
- Send a message to the frontend using
client.postMessage()
method
client.postMessage({
msg: "Hey I just got a fetch from you!",
url: event.request.url,
});
- Receive the message on the frontend by listening for the
"message"
event on thenavigator.serviceWorker.addEventListener()
method.
navigator.serviceWorker.addEventListener("message", (event) => {
// message data on event.data
});
So here is a full example of the service worker sending the message to the frontend.
addEventListener("fetch", (event) => {
event.waitUntil(
(async () => {
// Exit early if we don't have access to the client.
// Eg, if it's cross-origin.
if (!event.clientId) return;
// Get the client.
const client = await self.clients.get(event.clientId);
// Exit early if we don't get the client.
// Eg, if it closed.
if (!client) return;
// Send a message to the client.
client.postMessage({
msg: "Hey I just got a fetch from you!",
url: event.request.url,
});
})(),
);
});
navigator.serviceWorker.addEventListener("message", (event) => {
console.log(event.data.msg, event.data.url);
});
Push notifications
Push notifications are a way for your server to request a push server to send notifications to a service worker. Here are the components of a push notification setup:
- push server: A server dedicated for sending push notifications to a device. The browser owns this server
- web client: The client side on the web requests your server for subscription details, using your server's public key
- server: A server you setup with the purpose of delivering subscription details and public key to the browser and asking push server to send notification.
- service worker: The service worker receives push notifications sent by a push server through listening to the
"push"
event.
Here is the high-level overview:
- Client registers for a push subscription using your server's API key
- Client then sends push subscription details to your server via
POST
request so server can store the subscription info in a database - When the server wants to send a push notification, it queries stored subscription data to find the users it wants to ping, creates messages and encrypts them with private key, and sends those messages to the push server and also sends its public key.
- The push server verifies authenticity by decrypting the messages with your public key, and then triggers the
"push"
event on the service worker. - The service worker receives the
"push"
event, and then creates the notification. It can handle notification logic like it being clicked or closed.
client side
On the client side, there are two steps you need to do:
- Get server's public key
- Request the notifications permission through the
Notification
web API - Subscribe to push notifications using server's public key. This returns subscription info
- Send the subscription info to the server so it can store it in a database.
You can generate a public and private key pair for push notifications using this command:
npx web-push generate-vapid-keys
Here is the basic API for dealing with push notifications client side, all of which live on a ServiceWorkerRegistration
instance
serviceWorkerRegistration.pushManager.subscribe(options)
: subscribes to push notifications. Needs a valid public keyserviceWorkerRegistration.pushManager.getSubscription()
: gets the current subscription, if any.
Then subscribing to push is as simple as using this method:
const sw = await navigator.serviceWorker.ready;
const publicKey = "BEKleK9DGqPES0ONZTL-2WcHe50Gy22OTf8rEHbPv-JZ7i0tg_sLZStvcb9_PJHi6sGpK8HVXNy4Tz1qS0Ev6Qc"
const pushSubscription = sw.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
});
The subscription method returns a PushSubscription
instance, which has these properties and methods:
pushSubscription.toJSON()
: returns a JSON representation of the push subscriptionpushSubscription.unsubscribe()
: unsubs from the push notifications, invalidates the push endpoint.
After successfully subscribing, we need to add custom application logic to add the push subscription info to our server's db. We do this via a fetch request, but first, we can get the important subscription info from the subscription.toJSON()
convenience method.
The subscription data returned from subscription.toJSON()
looks like this:
{
"endpoint": "https://fcm.googleapis.com/fcm/send/dY6NbGBHmAo:APA91bHmGXpsUB6imlfX_WEQ6Nq9TB8nOXiJJ7ywYXUS-On4OU3-paUVqpgF7UNNKkbXqfwkIk2IOZ_-THReGKmORYtBf0C0zaxR5kfwhm780xekNWB8lomgyvpZ33sOlpHYpfrTHNv7",
"expirationTime": null,
"keys": {
"p256dh": "BP0xZjc331Gv72TnbzRWbmZ31ai1W-4xdqI8ZSb32YGgmUVYgMaVx6FHppvQ4GDg_w-uT562EPAmGbMQWcH9Gbw",
"auth": "B7S5SBfDcvsitJrZJUomrA"
}
}
"endpoint"
: unique for each device. This endpoint points to a push server and will be used to make the push notification."expirationTime"
: if set on the server, there will be an expiration time for the push subscription."keys"
: the necessary keys for encryption that you pass to the server, and then the server will pass to the push server.
Here's a full code example on the client side:
document
.getElementById("request-push-permission")
?.addEventListener("click", async () => {
// 0. request permission
const status = await PushNotificationManager.requestPermission();
if (status === "granted") {
// 1. grab the current worker
const currentWorker = await PWAServiceWorkerClient.getCurrentWorker();
if (!currentWorker) {
throw new Error("No worker found");
}
// 2. grab the public key from the server
const response = await fetch("/api/public-key");
const { publicKey } = await response.json();
if (!publicKey) {
throw new Error("No public key found");
}
// 3. subscribe to push
const subscription = await PushNotificationManager.subscribeToPush(
currentWorker,
publicKey
);
// 4. send the subscription data to the server
const parsedInfo = subscription.toJSON();
const subscribeResponse = await fetch("/api/subscribe", {
method: "POST",
body: JSON.stringify(parsedInfo),
});
const data = await subscribeResponse.json();
console.log("data", data.message);
}
});
And here is a custom class:
export class PushNotificationManager {
static getPushSubscription(sw: ServiceWorkerRegistration) {
return sw.pushManager.getSubscription();
}
static async sendPushInfoToServer(
subscription: PushSubscription,
url: string
) {
const parsedInfo = subscription.toJSON();
return await fetch(url, {
method: "POST",
body: JSON.stringify(parsedInfo),
});
}
static subscribeToPush(sw: ServiceWorkerRegistration, publicKey: string) {
return sw.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
});
}
static requestPermission() {
return Notification.requestPermission();
}
static get permissionIsGranted() {
return Notification.permission === "granted";
}
static get permissionIsDenied() {
return Notification.permission === "denied";
}
}
Server side
The server's main job is to sign messages we want to send with a private key, and then when we send that message to the push server, we give them the public key and if they can successfully decrypt our message, they will know that the integrity and authenticity of that message is reliable.
Let's go over the main routes we want to have in our application (arbitrarily named):
/push/subscribe
: this is a POST route that accepts subscription info from the client and should save it to a database/push/send
: this is a POST route that accepts subscription info from the client, queries a matching record in the database, and sends a push notification/push/broadcast
: this is a POST route that queries all push subscriptions from the database and sends a push notification to each one.
This is what the data that comes to your routes will look like, and how webpush expects the data to look like for representing a subscription.
interface Subscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
And here's a class I made that encapsulates the logic of using web push from the web-push
library:
webpush.setVapidDetails(mailto, private_key, public_key)
: initializes VAPID for push messaging.-
webpush.sendNotification(subscription, body, options?)
: sends a push notification based on the information from the subscription. Here are the options you have:
import webpush from "web-push";
export class WebPushVAPID {
public publicKey: string;
public privateKey: string;
constructor(options: { privateKey?: string; publicKey?: string } = {}) {
const privateKey = options.privateKey || import.meta.env.VAPID_PRIVATE_KEY;
const publicKey = options.publicKey || import.meta.env.VAPID_PUBLIC_KEY;
if (!privateKey || !publicKey) {
throw new Error("VAPID_PRIVATE_KEY or VAPID_PUBLIC_KEY is not set");
}
this.privateKey = privateKey;
this.publicKey = publicKey;
this.setVAPID()
}
setVAPID() {
webpush.setVapidDetails(
"mailto:somethingsomething@gmail.com",
this.publicKey,
this.privateKey
);
}
// for sending notification with payload.
async sendNotification(
subscription: {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
},
body: Record<string, unknown>
) {
const result = await webpush.sendNotification(
subscription,
JSON.stringify(body)
);
return result;
}
}
and here's a helper class I wrote that saves the push data to a sqlite database in bun:
class WebPushDBError extends Error {
constructor(message: string) {
super(message);
this.name = "WebPushDBError";
}
}
interface SubscriptionDB {
endpoint: string;
subscription_data: {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
};
}
export class WebPushDB {
private db: Database;
private getSubscriptionQuery: ReturnType<Database["prepare"]>;
constructor() {
this.db = new Database("web-push.db");
this.createTable();
this.getSubscriptionQuery = this.db.prepare(
"SELECT * FROM subscriptions WHERE endpoint = ?"
);
}
private createTable() {
this.db.run(
"CREATE TABLE IF NOT EXISTS subscriptions (endpoint TEXT PRIMARY KEY, subscription_data TEXT)"
);
}
static isWebPushDBError(error: unknown): error is WebPushDBError {
return error instanceof WebPushDBError;
}
getAllSubscriptions() {
const subscriptions = this.db
.prepare("SELECT * FROM subscriptions")
.all() as {
endpoint: string;
subscription_data: string;
}[];
const parsedSubscriptions = subscriptions.map((subscription) => ({
endpoint: subscription.endpoint,
subscription_data: JSON.parse(subscription.subscription_data),
})) as Subscription[];
return parsedSubscriptions;
}
addSubscription(subscription: {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}) {
try {
this.db.run(
"INSERT INTO subscriptions (endpoint, subscription_data) VALUES (?, ?) ON CONFLICT(endpoint) DO UPDATE SET subscription_data = ?",
[subscription.endpoint, JSON.stringify(subscription)]
);
} catch (error) {
if (WebPushDB.isWebPushDBError(error)) {
throw error;
}
throw new WebPushDBError(error as string);
}
}
getSubscription(endpoint: string) {
const subscription = this.getSubscriptionQuery.get(endpoint) as {
endpoint: string;
subscription_data: string;
};
if (!subscription) {
return null;
}
return {
endpoint: subscription.endpoint,
subscription_data: JSON.parse(subscription.subscription_data),
} as Subscription;
}
}
POST /push/subscribe
import {z} from "zod"
// Endpoint to save subscription from client
app.post('/push/subscribe', (req, res) => {
// 1. parse subscription from request body
const subscription = req.json() as Subscription
// 2. add subscription to database
addSubscriptionToDB(subscription)
console.log('New subscription:', subscription);
res.status(201).json({ message: 'Subscription saved' });
});
POST: sending messages
app.post('/broadcast', async (req, res) => {
const { title, body, icon, badge } = req.body;
// 1. construct
const payload = JSON.stringify({
title: title || 'Default Title',
body: body || 'Default body',
icon: icon || '/icon-192x192.png',
badge: badge || '/badge-72x72.png',
// Optional: add custom data
data: {
url: '/', // URL to open when notification is clicked
timestamp: Date.now()
}
});
// 2. get all subscriptions
const subscriptions = webPushDB.getAllSubscriptions();
// 3. for each subscription, send notification
for (const subscription of subscriptions) {
await webPushVAPID.sendNotification(subscription.subscription_data, {
body: "test notification",
title: "test title",
text: "test text",
});
}
res.send("done")
})
Service worker side
You can test out your push notifications triggering the "push"
event in the dev console:
There are 4 events you can listen to on the service worker related to push subscriptions:
"push"
: when a push notification is sent to the service worker"notificationclicked"
: when the notification is clicked
push event
The event that is received is a PushEvent
instance, which has these properties:
event.data.json()
: returns the data in a JSON format
You can then show a notification with the self.registration.showNotification()
method, whose basic syntax is like so:
self.registration.showNotification(title, options)
title
: the title of the notification.options
: the same exact options as the normal web notifications api
NOTE
Keep in mind that since a notification takes time to show and is asynchronous, you need to wrap it in an event.waitUntil()
call to make sure the service worker stays alive.
sw.addEventListener("push", async (event) => {
const pushEvent = event as PushEvent;
if (pushEvent.data) {
// 1. get data
const data = pushEvent.data.json();
// 2. wait until
pushEvent.waitUntil(
// 3. show notification
sw.registration.showNotification(data.title, {
body: data.body,
icon: "/icon.png",
requireInteraction: true,
silent: false,
})
);
}
});
on notification click
On notification click, a common pattern is to try and refocus to the web page or open the web page. We can do this through querying for service worker clients
IMPORTANT
The notificationclick
event in a service worker is the ONLY event in which you are allowed to open urls or refocus on a client.
Here is an example of this code:
sw.addEventListener("notificationclick", (event) => {
// 1. grab the notification
const notification = event.notification;
// 2. close the notification
notification.close();
const url = "/";
// 3. wrap all async methods in event.waitUntil()
event.waitUntil(
(async () => {
// 4. grab all window clients
const clients = await sw.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
// 5. if browser tab of our webpage is open already, focus it
if (clients.length > 0) {
return clients[0].focus();
}
// 6. else open our webpage.
return sw.clients.openWindow(url);
})()
);
});
custom class example
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="webworker" />
const sw = self as unknown as ServiceWorkerGlobalScope;
export class ServiceWorkerModel {
static onInstall(onInstall: (event: ExtendableEvent) => Promise<void>) {
sw.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
await onInstall(event);
await sw.skipWaiting();
})()
);
});
}
static onFetch(onRequest: (req: Request) => Promise<Response>) {
sw.addEventListener("fetch", (event) => {
event.respondWith(onRequest(event.request));
});
}
static onActivate(onActivate: (event: ExtendableEvent) => Promise<void>) {
sw.addEventListener("activate", (event) => {
const activate = async () => {
await onActivate(event);
await sw.clients.claim();
};
event.waitUntil(activate());
});
}
static onMessage(
onMessage: (event: ExtendableMessageEvent) => Promise<void>
) {
sw.addEventListener("message", (event) => {
event.waitUntil(onMessage(event));
});
}
static async notifyClients(cb: (client: WindowClient) => any) {
const clients = await sw.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
clients.forEach((client) => {
const message = cb(client);
client.postMessage(message);
});
}
static isValidCacheableRequest(
request: Request,
predicates?: ((request: Request) => boolean)[]
) {
const booleansThatRepresentInvalidURLStates = [
request.url.startsWith("chrome"),
request.url.startsWith("chrome-extension"),
];
return (
request.method === "GET" &&
booleansThatRepresentInvalidURLStates.every((b) => !b) &&
(predicates?.every((p) => p(request)) ?? true)
);
}
static onPush(
onPush: (eventData: PushEvent["data"] | null) => Promise<void>
) {
sw.addEventListener("push", (event) => {
if (event.data) {
event.waitUntil(onPush(event.data));
}
});
}
static getAllWindowClients() {
return sw.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
}
static getAllClients() {
return sw.clients.matchAll();
}
static openTab(url: string) {
return sw.clients.openWindow(url);
}
static onNotificationClick(
onNotificationClick: (notification: Notification) => Promise<any>
) {
sw.addEventListener("notificationclick", (event) => {
event.waitUntil(onNotificationClick(event.notification));
});
}
static showNotification(title: string, options: NotificationOptions) {
return sw.registration.showNotification(title, options);
}
static showBasicNotification(title: string, body: string, icon?: string) {
return sw.registration.showNotification(title, {
body,
icon,
requireInteraction: true,
silent: false,
});
}
static getNotificationClickHelpers(notification: Notification) {
return {
closeNotification: () => notification.close(),
focusOrOpenTab: async (url: string) => {
const clients = await sw.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
if (clients.length > 0) {
return clients[0].focus();
}
return sw.clients.openWindow(url);
},
};
}
}
And here's how to use them:
ServiceWorkerModel.onPush(async (eventData) => {
console.log("Received a push message", eventData);
const data = eventData?.json();
console.log("data from push", data);
ServiceWorkerModel.showBasicNotification(data.title, data.body, "/icon.png");
});
ServiceWorkerModel.onNotificationClick(async (notification) => {
console.log("Received a notification click", notification);
const data = notification.data;
console.log("data from notification", data);
const { closeNotification, focusOrOpenTab } =
ServiceWorkerModel.getNotificationClickHelpers(notification);
closeNotification();
return await focusOrOpenTab("/");
});
Updating service workers
It's necessary to master updating service workers because otherwise your cached content will always be stale and users will always see the stale version of your website. Follow these steps for a general updating process.
WARNING
Even if you update your service worker, the cached assets will not be automatically updated. You have to manually update the cache.
- In the service worker, whenever you update the version of the service worker, write code to invalidate the previous cache.
- Listen for service worker installation on the frontend
async function detectSWUpdate() {
const registration = await navigator.serviceWorker.ready;
registration.addEventListener("updatefound", event => {
const newSW = registration.installing;
newSW.addEventListener("statechange", event => {
if (newSW.state == "installed") {
// New service worker is installed, but waiting activation
}
});
})
}
- Display a toast message telling a user that a new version of the app can be installed. Then when the user allows the new version to be installed, send a message to the service worker to update the cache and then reload the user's page.
Developing with service workers
- You can see all your registered service workers at the
chrome://serviceworker-internals/
link. - You should also check the update on reload checkbox to make sure that your service workers update when you reload them.
- To bypass the service worker on a reload, you can hold the
shift
key while you try to reload.
Web Storage
Storage permissions
Although databases like indexedDB and cache storage can store gigabytes of data, the browser is free to destroy those data reserves if the system doesn't have much storage left. To prevent this from occurring, you can prompt the user to keep your storage at all cost to prevent the browser from freeing up the data under your origin.
Asking for persistent storage
There are two types of storage persistence behavior that storage APIs can have:
- best effort: If hard drive space is running low, the browser will delete data from some origins
- persistence: Only if the user wants to delete the data will the data be deleted.
You have these two methods to handle permissions around persistent storage:
await navigator.storage.persist()
: asks the browser to allow persistent storageawait navigator.storage.persisted()
: returns whether or not the website was allowed to use persistent storage
export async function askPersistStorage() {
const isPersisted = await navigator.storage.persisted();
if (!isPersisted) {
const isPersistGranted = await navigator.storage.persist();
}
}
Getting data storage amount
You can ask for an estimate for the amount of web storage used on your entire device by using the navigator.storage.estimate()
method, which returns a quota object.
quota.usage
: returns the amount of web storage you have used in bytesquota.quota
: returns the amount of hard disk space you have available to use for web storage in bytes
export async function askQuota() {
const quota = await navigator.storage.estimate();
if (quota.usage && quota.quota) {
console.log(
`Used ${(quota.usage / 1024 / 1024).toFixed(2)}mb of ${(
quota.quota /
1024 /
1024 /
1024
).toFixed(2)}gb`
);
const percentUsed = (quota.usage / quota.quota) * 100;
console.log(`Percent used: ${percentUsed.toFixed(2)}%`);
return percentUsed;
}
}
Summary
Here is a class that covers all cases:
export class NavigatorStorageManager {
async askPersistStorage() {
const isPersisted = await navigator.storage.persisted();
if (!isPersisted) {
const isPersistGranted = await navigator.storage.persist();
return isPersistGranted;
}
return isPersisted;
}
async getStorageInfo() {
const info = await FileSystemManager.getStorageInfo();
return {
percentUsed: info.storagePercentageUsed,
bytesUsed: humanFileSize(info.bytesUsed),
bytesAvailable: humanFileSize(info.bytesAvailable),
};
}
}
export function humanFileSize(bytes: number, dp = 1) {
const thresh = 1000;
if (Math.abs(bytes) < thresh) {
return bytes + " B";
}
const units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
let u = -1;
const r = 10 ** dp;
do {
bytes /= thresh;
++u;
} while (
Math.round(Math.abs(bytes) * r) / r >= thresh &&
u < units.length - 1
);
return bytes.toFixed(dp) + " " + units[u];
}
IndexedDB
IndexedDB is a low-level, callback based asynchronous database API that can be used from both the frontend and the service worker.
Indexed db is split up into a hierarchy like so: databases → data stores → objects
You can have multiple databases, and in those databases you can add data stores, and then you can add individual objects into those data stores.
Because indexedDB is so hard to use, we use third party wrappers around them:
LocalForage
The localforage
library works like local storage but uses IDB under the hood. It is an asynchronous library.
There is no need to stringify or parse objects, since this library will do that under the hood.
Setup
import * as localforage from "localforage";
// must set up database before interacting with it
localforage.config({
driver: localforage.INDEXEDDB,
name: "coffeemasters",
version: 1.0,
description: "Coffeemasters local storage",
});
Before running any database methods, you have to configure the idb database with localforage.config()
, passing in options to configure the database like the database name and version.
API
You can create object stores with localforage.createInstance()
, passing in an object of options. The name
key is the store name.
const store = await localforage.createInstance({
name: "myStore"
});
You then have these methods available, both on localforage and any localforage instances:
localforage.setItem(key, value)
: sets the itemlocalforage.getItem(key)
: gets the item. Returns null otherwiselocalforage.length()
: returns the number of entries in the database or object store.
Custom class
export class LocalForage<T = string> {
private store: globalThis.LocalForage;
constructor(name: string) {
this.store = localforage.createInstance({
name,
});
}
async set(key: string, value: T) {
await this.store.setItem(key, value);
}
async get(key: string) {
try {
const val = await this.store.getItem<T>(key);
return val;
} catch (e) {
console.log(e);
throw e;
}
}
async size() {
return await this.store.length();
}
}
IDB
IDB is a tiny promise wrapper around the normal indexedDB API, and is the closest thing to the normal API.
setup
You must open the database first and then define the upgrade()
callback, which is the only place you can initialize object stores and create indices.
- object stores: data stores for individual objects
- indices: Like SQL indices where you can speed up performance by sorting across specified fields
export const db = await openDB(databaseName, versionNumber, {
async upgrade(db) {
// create stores and indices
},
});
creating stores and indices
db.createObjectStore(storeName)
: creates an returns an object store with the specified name.store.createIndex(indexName, property)
: creates an index in the store around the specified property of the objects in the store, boosting performance around queries involving that property.
db methods
All these methods live on the database rather than the store itself, meaning that you have to pass in the store name each time as the first argument so that indexedDB will know which store you want to run database operations on.
db.add(storeName, obj)
: adds the item to the store with the specified namedb.put(storeName, key, obj)
: updates the item in the storedb.get(storeName, key)
: returns the item from the specified store and specified name
Example
import { openDB, deleteDB, wrap, unwrap, IDBPObjectStore } from "idb";
// create database
export const db = await openDB("coffeemasters", 1, {
async upgrade(db) {
const cartStore = await createStore("cart");
},
});
async function createStore(name: string) {
return await db.createObjectStore(name);
}
export class IDBStore {
// this class stores the storeName as the private variable
constructor(private storeName: string) {
// creates store if not created already
if (!db.objectStoreNames.contains(storeName)) {
createStore(storeName);
}
}
async set(key: string, value: any) {
return await db.put(this.storeName, value, key);
}
async get(key: string) {
return await db.get(this.storeName, key);
}
async getAll() {
return await db.getAll(this.storeName);
}
deleteStore() {
db.deleteObjectStore(this.storeName);
}
async add(value: any) {
return await db.add(this.storeName, value);
}
async getStoreSize() {
return await db.count(this.storeName);
}
async clear() {
await db.clear(this.storeName);
}
async delete(key: string) {
await db.delete(this.storeName, key);
}
}
PWA development
You need two things to create a PWA: a service worker and a manifest.json. PWAs stand for progressive web apps, and are the bridge between native apps and websites.
NOTE
Philosophy of the PWA A PWA should be offline-capable through the service worker, offer a performant experience by using some caching strategy to cache network requests, and offer a native app experience by moving away from common web standards.
PWA tools
Vite PWA plugin
Use the vite PWA plugin to easily turn your app into a PWA.
npm install -D vite-plugin-pwa
- Add the plugin to the vite config:
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true
}
})
]
})
To make your app offline capable, you need to add PWA meta tags to your HTML and also specify a manifest:
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>My Awesome App</title>
<meta name="description" content="My Awesome App description">
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<meta name="theme-color" content="#ffffff">
</head>
Then you can specify a manifest like so:
- The icons are fetched specifically with vite, so instead of
public/icon.png
, it just becomes/icon.png
. - We specify what assets to precache through the
workbox.globOptions
key
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
tailwindcss(),
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "PWA App",
short_name: "PWA App",
description: "PWA App",
theme_color: "#2d73a6",
background_color: "#ffffff",
start_url: "/",
display: "standalone",
icons: [
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable",
},
],
},
devOptions: {
enabled: false,
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"], // precaches all assets
},
}),
],
});
The final step is to add a public/robots.txt
file:
User-agent: *
Allow: /
Basics
Inside the VitePWA
plugin, you have access to these options:
registerType
: can be one of two values:"autoUpdate"
: if set to this, then the service worker will update automatically when it gets modified."prompt"
: the default behavior. The user will be prompted if they want to update to a new service worker.
devOptions.enabled
: if set to true, you will be able to use the service worker in development as well, which is useful for debugging.
Workbox options
IN the workbox
key you can provide all sorts of caching strategies to assets and routes. It is basically like using workbox without having to write the code yourself.
workbox.globPatterns
: a list of glob patterns to precache as the app shell
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'], // precache all relevant files
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}
})
Vite PWA Assets plugin
Use the vite PWA assets plugin to dynamically generate icons of different sizes for your PWA.
npm install -D @vite-pwa/assets-generator
pwa-assets-generator --preset minimal-2023 <icon-file-path>
pwa-assets-generator --preset minimal-2023 public/logo.svg
Creating icons automatically
Go to this great site to create 20+ PWA images just from one image:
PWA builder
The PWA builder organization is funded by microsoft and helps scaffold out boilerplates for PWA development.
PWA CLI
- Install the PWA builder extension in VS code.
- Create a new PWA project with
npx @pwabuilder/cli create
- Start the pwa with
pwa start
- Build the pwa with
pwa build
Deploying the PWA
To package your PWA, you need to have it first published to the web and then build it. Here are steps for doing so with PWA builder:
- Hit in
ctrl-shift-P
in VS Code and search forPWABuilder Studio: Set App URL
. - Select
Yes
if you already have a URL. - Provide the URL for your web application.
- Hit
ctrl-shift-P
and run the commandPWABuilder Studio: Package your PWA
. - Select which platform you wish to package your PWA for.
- Follow the prompts for the platform you selected.
- Your PWA’s package will be generated!
Go here to learn more about how to deploy your PWA using PWAbuilder.
Also go here for more info on how to submit to stores `
Creating icons
You can use the visual studio code extension to automatically create a bunch of icons for the PWA.
To generate icons:
- Ensure you have a web manifest.
- Hit
ctrl-shift-P
with Code open. - Search for and run the command
PWABuilder Studio: Generate Icons
- The generate icon panel will open up and ask you to select a base icon. This icon is used to generate all of the correctly-sized icons for your PWA. You can also tweak options such as icon padding and background color.
- Click
Generate Icons
. - Your icons will be automatically added to your web manifest.
Creating screenshots
App stores and the browser install prompt will also use Screenshots of your app. The PWABuilder Studio extension can help you generate the correct sized screenshots for your application, using the URL to your deployed app, and add them directly to your manifest.
To generate icons:
- Ensure you have a web manifest.
- Hit
ctrl-shift-P
and run the commandPWABuilder Studio: Generate Screenshots
. - The generate screenshot dialogue will open up and ask you to enter the URL to your app.
- Click
Generate Screenshots
. - Your screenshots will be automatically added to your web manifest.
PWA Manifest
Creating a web manifest is the first step to register your app as a PWA. Create an app.webmanifest
, which is automatically recognized as JSON. Then link the manifest to your app by adding the <link rel="manifest"/>
tag to your HTML.
- Create
app.webmanifest
- Link to it in HTML
<link rel="manifest" href="app.webmanifest">
- Add a meta theme tag to style the title bar for the app
<meta name="theme-color" content="green" />
Here is what a typical PWA manifest looks like:
{
"id": "/",
"scope": "/",
"lang": "en-us",
"name": "Repose intelligent daily mood journal",
"display": "standalone",
"start_url": "/",
"short_name": "Repose",
"theme_color": "#B6E2D3",
"description": "Repose is a mental health journal app that serves as your personal mood tracking companion and helps you organize and reflect upon your daily thoughts.",
"orientation": "any",
"background_color": "#FAE8E0",
"dir": "ltr",
"related_applications": [],
"prefer_related_applications": false,
"display_override": ["window-controls-overlay"],
"icons": [
{
"src": "assets/icons/512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "assets/icons/192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/icons/48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "assets/icons/24x24.png",
"sizes": "24x24",
"type": "image/png"
}
],
"screenshots": [
{
"src": "assets/screenshots/screen.png",
"sizes": "1617x1012",
"type": "image/png"
}
],
"features": [
"Cross Platform",
"fast",
"simple"
],
"categories": [
"social"
],
"shortcuts": [
{
"name": "New Journal",
"short_name": "Journal",
"description": "Write a new journal",
"url": "/form",
"icons": [{ "src": "assets/icons/icon_192.png", "sizes": "192x192" }]
}
],
"widgets": [
{
"name": "Starter Widget",
"tag": "starterWidget",
"ms_ac_template": "widget/ac.json",
"data": "widget/data.json",
"description": "A simple widget example from pwa-starter.",
"screenshots": [
{
"src": "assets/screenshots/widget-screen.png",
"sizes": "500x500",
"label": "Widget screenshot"
}
],
"icons": [
{
"src": "assets/icons/48x48.png",
"sizes": "48x48"
}
]
}
]
}
name
: the app name. Requiredshort_name
: the shortened version of the app name. Keep this to less than 12 characters.start_url
: the start route for the app. Should pretty much always be./
for the home screen. Requireddescription
: the app’s descriptionbackground_color
: the color for the splash screen, and should be a hex code. Requiredtheme_color
: the color for the splash screen and title bar of your app, and should be a hex code.screenshots
: screenshots of your app to have richer preview when prompting an installation of the app on android.categories
: an array of strings. Represents the categories of your appscope
: Changes the navigation scope of the PWA, allowing you to define what is and isn't displayed within the installed app's window. For example, if you link to a page outside of the scope, it will be rendered in an in-app browser instead of within your PWA window. This will not, however, change the scope of your service worker.display
: a required value. It can be one of these values:"standalone"
: allows your app to stand as a standalone app, meaning it can be downloaded as apk, ios, and more. This is the behavior you want 90% of the time. It also has most browser support."fullscreen"
: registers as fullscreen app. Only supported on Android"minimal-ui"
: halfway between browser and full-fledged PWA. Not supported by IOS
adding autocomplete
You can add autocomplete and language support to your app.webmanifest
or manifest.json
by adding this schema:
{
"$schema": "https://json.schemastore.org/web-manifest-combined.json"
}
Icons
The "icons"
key in the manifest sets up the icons for your app. It is an array of objects, where each object is info about the icon. You are required to provide three icons:
- A 512 x 512 icon
- A 1024 x 1024 icon
- A 512 x 512 maskable icon, which means that it looks good on any phone OS.
To create a maskable icon with enough padding, go to the site below:
While the default "icons"
key will work for all platforms, you can override the icons for IPhone and provide your own custom ones with these <link>
tags.
<link rel="apple-touch-icon" href="/icons/ios.png">
180 x 180 is the recommended size for the IPhone icon.
Splash screen
Splash screens on Android use basic values from the manifest to create a default splash screen:
theme_color
: the color of the status barbackground_color
: the background color of the splash screen
Getting a splash screen to show up on Apple is an entirely different beast. You need to provide a <meta>
and <link>
tag, and the splash screen image you use must be EXACTLY the size of the phone screen.
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-startup-image" href="splash.png">
This leads to over 20 different versions of these meta tags just to accommodate for all the different IPhone sizes, so instead use one of these two solutions:
- PWCompat: a library that automatically generates all splash screens and icons from manifest using JavaScript
- PWA Assets Generator: A CLI tool that generates all the different versions of the splash screens.
Shortcuts
Shortcuts are a way to allow deep links into your app from a user friendly prompt. Define them like so, under the "shortcuts"
key:
"shortcuts": [
{
"name": "News Feed", // the shortcut name
"short_name": "Feed",
"url": "/feed", // the route the shortcut should open
"description": "Noteworthy news from today.",
"icons": [ // a custom 96x96 icon. Overrides the default.
{
"src": "assets/icons/news.png",
"type": "image/png",
"purpose": "any"
}
]
}
]
The shortcuts
member is an array of shortcut
objects, which can contain the following members:
name
: The display name of the shortcut. Required memberurl
: The url that the shortcut will open to. Required membershort_name
: The shortened display name for when display space is limited.description
: A string description of the shortcut.icons
: A set of icons used to represent the shortcut. This array must include a 96x96 icon.
Richer PWA install UI
To get a cool looking install UI like this one below from squoosh, we have to put additional details in our manifest, which includes screenshots.
You do this through adding an array of "screenshots"
{
// ...
"screenshots": [
{
"src": "icons/screenshot.png",
"sizes": "1170x2532",
"type": "image/png"
}
]
}
PWA UX
For a good UX, a PWA should feel as much like a native app as possible. When users install a PWA on their phone, they expect app-like behavior.
Here are also some standard UX design tips for PWAs:
- Don't use a big header area like websites do for navigation to other pages. Use a menu metaphor instead.
- Don't use a big footer area like websites do for more links and information.
- Use the
system-ui
font to make your content feel more native and load faster.
Badging
You can use the navigator.setAppBadge(count)
method to set the badge for the app, which is useful for alerting the user for unread notifications and stuff like that.
const badgeNumber = 10
navigator.setAppBadge(badgeNumber);
Here's a custom class:
export class PWABadger {
static isBadgeSupported() {
return "setAppBadge" in navigator && "clearAppBadge" in navigator;
}
static async setBadge(badge: number) {
if (navigator.setAppBadge) {
await navigator.setAppBadge(badge);
}
}
static async clearBadge() {
if (navigator.clearAppBadge) {
await navigator.clearAppBadge();
}
}
}
Targeting displays
You can style different PWA experiences depending on their "display_mode"
manifest key. Do it using media queries:
@media (display-mode: standalone) {
/* code for standalone PWAs here */
}
Disabling text selection
Disable user text selection on UI elements like buttons by using the CSS property user-select: none
.elements {
user-select: none;
}
Covering the IPhone notch: SafeAreaView
On phones with notches and safe areas like the iPhone 13, you need to request full screen access so that your content can show under the notch. This is how you do it in your HTML:
<meta name="viewport" content="width=device-width,viewport-fit=cover" />
However, once you request covering the viewport, you should make sure to pad your content into the safe area view, like so:
.container {
padding: env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left) !important;
}
You can also add default values if you are not on an IPhone:
.container {
padding: env(safe-area-inset-top, 5px)
env(safe-area-inset-right, 5px)
env(safe-area-inset-bottom, 5px)
env(safe-area-inset-left, 5px) !important;
}
Changing status bar style
You can change the status bar style using a meta tag:
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"
This is the difference between black
and black-translucent
:
Check if in PWA
Below is some code for how to detect whether the user is using your PWA as a website or if they installed it and are using it as a PWA.
window.addEventListener('DOMContentLoaded', () => {
let displayMode = 'browser tab';
if (window.matchMedia('(display-mode: standalone)').matches) {
displayMode = 'standalone';
}
// Log launch display mode to analytics
console.log('DISPLAY_MODE_LAUNCH:', displayMode);
});
Installing the PWA and checking installation
On Apple, the user can only download the PWA from Safari, and custom PWA installation will not work. It is also recommended to have this icon in your main HTML to enable PWA installation.
<link rel="apple-touch-icon" href="/icons/ios.png">
You can check for if the user already installed the PWA by listening to the "appinstalled"
event on the window. This is useful to remove any custom install buttons.
window.addEventListener('appinstalled', () => {
// If visible, hide the install promotion
hideInAppInstallPromotion();
// Log install to analytics
console.log('INSTALL: Success');
});
Using the code below, you can attach the behavior of prompting the user to install the PWA to a button click:
- We listen for the
"beforeinstallprompt"
event on thewindow
and store that event as a global variable calledbipEvent
- On a user gesture, we call the
bipEvent.prompt()
async method to bring up the installation prompt. This returns an outcome of'accepted'
(user installed) or'dismissed'
(user rejected).
// 1. create an event variable
let bipEvent: Event | null = null;
// 2. store the event
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
bipEvent = event;
});
// 3. prompt for app installation with bipEvent.prompt()
document.querySelector("#btnInstall")!.addEventListener("click", async (event) => {
if (bipEvent) {
await bipEvent.prompt();
const {outcome} = await bipEvent.userChoice
// must reset afterwards
bipEvent = null
if (outcome === 'accepted') {
console.log('User accepted the install prompt.');
} else if (outcome === 'dismissed') {
console.log('User dismissed the install prompt');
}
} else {
// incompatible browser, your PWA is not passing the criteria, the user has already installed the PWA
alert(
"To install the app look for Add to Homescreen or Install in your browser's menu"
);
}
});
You can check all your installed PWAs at the chrome://apps/ link.
Custom PWA class
Here is a custom PWA class that handles the javascript side of PWA ux:
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: "accepted" | "dismissed";
platform: string;
}>;
prompt(): Promise<void>;
}
export class PWAModel {
static async registerWorker(url: string) {
return await navigator.serviceWorker.register(url, {
type: "module",
scope: "/",
});
}
static isInPWA() {
let displayMode = "browser tab";
if (window.matchMedia("(display-mode: standalone)").matches) {
displayMode = "standalone";
}
return displayMode === "standalone";
}
/**
* runs when the app is installed
* @param cb callback function to run when the app is installed
*/
static onAppInstalled(cb: () => void) {
window.addEventListener("appinstalled", cb);
}
static installPWA() {
let bipEvent: BeforeInstallPromptEvent | null = null;
return {
install: async () => {
if (bipEvent) {
await bipEvent.prompt();
const choiceResult = await bipEvent.userChoice;
console.log(bipEvent);
return choiceResult.outcome === "accepted";
} else {
throw new Error("Install prompt not available");
}
},
setupInstallPrompt: () => {
// if the app is not in display mode, and the install prompt is available, then we can install
window.addEventListener("beforeinstallprompt", (event: Event) => {
// event.preventDefault();
bipEvent = event as BeforeInstallPromptEvent;
});
},
};
}
static showInstallPromptBanner({
banner,
installButton,
onInstallSuccess,
onInstallFailure,
onAlreadyInstalled,
}: {
banner: HTMLElement;
installButton: HTMLButtonElement;
onInstallSuccess?: () => void;
onInstallFailure?: () => void;
onAlreadyInstalled?: () => void;
}) {
banner.style.display = "block";
const { setupInstallPrompt, install } = PWAModel.installPWA();
// 1. register the install prompt with event listener
setupInstallPrompt();
if (!PWAModel.isInPWA()) {
// 2. add event listener to button for installation, remove banner on success.
const controller = new AbortController();
installButton.addEventListener(
"click",
async () => {
const success = await install();
if (success) {
banner.remove();
controller.abort();
onInstallSuccess?.();
} else {
onInstallFailure?.();
}
},
{
signal: controller.signal,
}
);
// 3. add the banner to the body if it's not already there
if (!document.body.contains(banner)) {
document.body.appendChild(banner);
} else {
banner.style.display = "block";
}
} else {
onAlreadyInstalled?.();
}
}
}
You can then install a PWA like this:
const appBanner = DOM.createDomElement(html`
<div
class="fixed bottom-4 left-4 bg-white/75 py-2 px-8 text-center rounded-lg shadow-lg space-y-2 z-50 border-2 border-gray-300"
>
<p class="text-sm">Install App?</p>
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md text-sm cursor-pointer"
>
Install
</button>
</div>
`);
const appBanner$throw = DOM.createQuerySelectorWithThrow(appBanner);
PWAModel.showInstallPromptBanner({
banner: appBanner,
installButton: appBanner$throw("button")!,
onAlreadyInstalled: () => {
Toaster.info("already installed");
},
onInstallFailure: () => {
Toaster.danger("user refused to install");
},
onInstallSuccess: () => {
Toaster.info("user installed our malware successfully!");
},
});
PWA APIs
Share API
We can use the web share API with the navigator.share()
method to have native sharing abilities:
await navigator.share({
title: "MDN",
text: "Learn web development on MDN!",
url: "https://developer.mozilla.org",
})
url
: the url to be sharedtext
: the description of what you’re sharingtitle
: the share titlefiles
: an array ofFile
objects to share.