PWA Wiki
Service worker
- Example service worker
Getting started with Workbox
Include this inside your Service Worker file (e.g. sw.js
)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.0.0/workbox-sw.js');
if (workbox) {
console.log(`Yay! Workbox is loaded 🎉`);
} else {
console.log(`Boo! Workbox didn't load 😬`);
}
Caching
Workbox Precaching
- Get Workbox Precache Cache:
const cache = await caches.open(workbox.core.cacheNames.precache);
workbox.precaching.precacheAndRoute(['/', 'index.html'], 'GET');
Workbox Caching Strategies
workbox.routing.registerRoute(
/\.(?:js|css)$/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'css_js'
})
);
- HTML page caching
workbox.routing.registerRoute( /(\/|index\.html)$/, new workbox.strategies.NetworkFirst() );
- Image caching
workbox.routing.registerRoute( /\.(?:png|gif|jpg|jpeg|svg|webp)$/, new workbox.strategies.StaleWhileRevalidate({ cacheName: 'images', plugins: [ new workbox.expiration.ExpirationPlugin({ // Only cache 60 images. maxEntries: 60, purgeOnQuotaError: true }) ] }) );
- Font caching
workbox.routing.registerRoute( /\.(?:woff|woff2|ttf|otf|eot)$/, new workbox.strategies.StaleWhileRevalidate({ cacheName: 'fonts' }) );
Workbox Preloading Routes
Registration
/* app.js */
const SERVICE_WORKER_SCOPE = '/';
window.addEventListener('load', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('./service-worker.js', { scope: SERVICE_WORKER_SCOPE });
}
});
Updating
workbox.skipWaiting();
workbox.clientsClaim();
Install event
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(cacheName).then(function(cache) {
return cache.addAll(
[
'/css/main.min.css',
'/js/app.min.js',
'/offline.html'
]
);
})
);
});
Activate event
self.addEventListener('activate', async event => {
await self.registration.navigationPreload.enable();
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.filter(function(cacheName) {
// Return true if you want to remove this cache,
// but remember that caches are shared across
// the whole origin
}).map(function(cacheName) {
return caches.delete(cacheName);
})
);
})
);
});
Fetch event
// when the browser requests a resource
addEventListener('fetch', event => {
event.respondWith(
// look in the cache for the resource
caches.match(event.request).then(async response => {
if (response) {
// is in cache, respond with the cached resource
return response;
}
// if not found fetch it from the network
const networkResponse = await fetch(event.request);
// response needs to be cloned if going to be used more than once
const clonedResponse = networkResponse.clone();
// save response to runtime cache for later use
const runtimeCache = await caches.open('runtime-cache');
runtimeCache.put(event.request, networkResponse);
// respond with the cloned network response
return Promise.resolve(clonedResponse);
})
);
});
Offline
Default offline page
Workbox
importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.0.0/workbox-sw.js');
const OFFLINE_HTML = '/offline.html';
const PRECACHE = [
OFFLINE_HTML
];
workbox.precaching.precacheAndRoute(PRECACHE);
const htmlHandler = new workbox.strategies.NetworkOnly();
// A NavigationRoute matches navigation requests in the browser, i.e. requests for HTML.
const navigationRoute = new workbox.routing.NavigationRoute(({ event }) => {
const request = event.request;
return htmlHandler.handle({ event, request }).catch(() => caches.match(OFFLINE_HTML, {
ignoreSearch: true
}));
});
workbox.routing.registerRoute(navigationRoute);
Vanilla JS
addEventListener('fetch', async event => {
if (event.request.mode === 'navigate') {
event.respondWith((async () => {
const staleWhileRevalidate = new workbox.strategies.StaleWhileRevalidate();
const url = event.request.url;
try {
// check if the event.request.url exists in the cache or in the network
const response = await caches.match(event.request) || await fetch(event.request);
if (!response || response.status === 404) {
throw new Error(response.status);
} else {
return await staleWhileRevalidate.handle({event});
}
} catch (error) {
// if not found in any of the two, respond with the offline.html file
console.warn(`ServiceWorker: ${url} was not found either in the network or the cache. Responding with offline page instead.\n`);
return await caches.match(OFFLINE_PAGE_URL) || await fetch(OFFLINE_PAGE_URL, { method: 'GET' });
}
})());
}
});
Mark offline unavailable pages
- Register a route for pages to cache -
service-worker.js
// * store projects' media in a different runtime cache // * checking later inside this cache to decide which projects are available offline workbox.routing.registerRoute( /\.(?:png|gif|webp|mp4|webm)$/, new workbox.strategies.CacheFirst({ cacheName: 'runtime-pages-media' }) );
- Mark pages when offline -
app.js
- Check offline status
- on startup / window load
if (!navigator.connection.downlink) { // is offline }
- on change of network conditions
window.addEventListener('offline', markOfflineUnavailablePages); window.addEventListener('online', unmarkOfflineUnavailablePages);
- Handle offline event
const markOfflineUnavailableProjects = () => { const pages = document.querySelectorAll('a.page-link'); caches.open('runtime-pages-media').then(pagesRuntimeCache => { pages.forEach(page => { pagesRuntimeCache.keys().then(keys => { const cachedPage = keys.find(key => key.url === page.href); if (!cachedPage) { // mark page as unavailable while offline product.classList.add('unavailable-offline'); } else { // add the page to a list of offline available pages } }); }); }); }
- Check offline status
- Add styling -
styles.css
.unavailable-offline { opacity: 0.25; pointer-events: none; }
Push notifications
- Subscribing a user — Google Developers article
Push examples website
- Press "I Want Push!" and wait for the subscription to be saved by the API
- Press on any of the types of notifications under "Request Push Notifications"
Glitch Repo
Background Sync
Vanilla JavaScript
Replaying offline requests example website
Workbox
Glitch repo
Placeholder image fallback
- Add code inside
service-worker.js
only
const placeholderImageURL = '/img/placeholder-image.png'; // precache this in __precacheManifest file
workbox.precaching.precacheAndRoute(self.__precacheManifest || []);
workbox.routing.registerRoute(
/\.(?:webp|png|jpg|jpeg|svg)$/,
async ({url, event, params}) => {
const staleWhileRevalidate = new workbox.strategies.StaleWhileRevalidate();
try {
const response = await caches.match(event.request) || await fetch(url, { method: 'GET' });
if (!response || response.status === 404) {
throw new Error(response.status);
} else {
return await staleWhileRevalidate.handle({event});
}
} catch (error) {
console.warn(`\nServiceWorker: Image [${url.href}] was not found either in the network or the cache. Responding with placeholder image instead.\n`);
// * get placeholder image from cache || get placeholder image from network
return await caches.match(placeholderImageURL) || await fetch(placeholderImageURL, { method: 'GET' });
}
}
);
Offer reload on new version
- Skip waiting and claim all clients within the service worker
self.addEventListener('message', event => { if (event.data && event.data.type === 'NEW_VERSION') { workbox.core.clientsClaim(); workbox.core.skipWaiting(); } });
- Attach event listeners during registration
import { Workbox } from 'workbox-window'; var workbox; const registerServiceWorker = () => { if ('serviceWorker' in navigator) { workBox = new Workbox('./service-worker.js', { scope: '/' }); workBox.addEventListener('waiting' , () => { const updateServiceWorker = event => { event.preventDefault(); workBox.addEventListener('controlling', () => { window.location.reload(); }); workBox.messageSW({ type: 'NEW_VERSION'}); }; window.updateServiceWorker = updateServiceWorker; setTimeout(() => showSnackBar('Reload to see new version <span style="font-size:17px;margin-left:5px">👉</span><a href="#" onclick="window.updateServiceWorker();">↻</a>') , 1500 ); }); workBox.register(); } }
Vanilla JS (not recommended)
- This method is not recommended as inconsistencies where observed during testing.
const isNewVersionAvailable = await navigator.serviceWorker.register('service-worker.js').then(checkForNewVersion); const checkForNewVersion = registration => { return new Promise((resolve, reject) => { registration.onupdatefound = () => { const installingWorker = registration.installing; installingWorker.onstatechange = () => { switch (installingWorker.state) { case 'installed': if (navigator.serviceWorker.controller) { // new update available resolve(true); } else { // no update available resolve(false); } break; } }; }; }); }
Custom pull-to-refresh effect
- Article: Take control of your scroll: customizing pull-to-refresh and overflow effects — Google Developers
Install - Add to Home screen
- Check if native app is installed -
getInstalledRelatedApps()
(currently Origin Trial) - web.dev
Install button
NPM
- Install npm package:
npm install @pwabuilder/pwainstall
- Import inside your app scripts:
import '@pwabuilder/pwainstall'
CDN
- Include module in
index.html
:<script type="module" src="https://cdn.jsdelivr.net/npm/@pwabuilder/pwainstall" ></script>
Then you can use the element <pwa-install></pwa-install>
inside your html files.
Custom implementation
Register service worker
- In
app.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then(registration => {
});
}
Catching Install event
let deferredPromptEvent;
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPromptEvent = e;
installButton.style.display = 'block';
});
Install button event listener
const installButton = document.getElementById('install-button');
installButton.addEventListener('click', function() {
installButton.style.display = 'none';
deferredPromptEvent.prompt();
deferredPromptEvent.userChoice.then(function(choiceResult) {
console.log(choiceResult.outcome) // 'dismissed' or 'accepted'
deferredPromptEvent = null;
});
});
Install banner for iOS (≤ 13)
// Detects if device is an iOS (including iOS 13)
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// Detects if device is in standalone mode
const isInStandaloneMode = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
// Checks if should display install popup notification:
if (isIos && !isInStandaloneMode) {
this.setState({ showInstallMessage: true });
}
Web Manifest
Fullscreen mode on iOS
<meta name=”apple-mobile-web-app-capable” content=”yes”>
Design
Icon Generators
Favicons - Maskable icon
...
"icons": [
{
"src": "./favicon/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "./favicon/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "./favicon/favicon-64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "./favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "./favicon/maskable_icon.png",
"sizes": "196x196",
"type": "image/png",
"purpose": "maskable"
}
],
...
Windows
<meta name="msapplication-square70x70logo" content="icon_smalltile.png">
<meta name="msapplication-square150x150logo" content="icon_mediumtile.png">
<meta name="msapplication-wide310x150logo" content="icon_widetile.png">
iOS
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Example PWA">
<link href="icon-152x152.png" rel="apple-touch-icon">
iOS Splash screen
<link href="/apple_splash_2048.png" sizes="2048x2732" rel="apple-touch-startup-image" />
<link href="/apple_splash_1668.png" sizes="1668x2224" rel="apple-touch-startup-image" />
<link href="/apple_splash_1536.png" sizes="1536x2048" rel="apple-touch-startup-image" />
<link href="/apple_splash_1125.png" sizes="1125x2436" rel="apple-touch-startup-image" />
<link href="/apple_splash_1242.png" sizes="1242x2208" rel="apple-touch-startup-image" />
<link href="/apple_splash_750.png" sizes="750x1334" rel="apple-touch-startup-image" />
<link href="/apple_splash_640.png" sizes="640x1136" rel="apple-touch-startup-image" />
Using system fonts
index.html
<!-- ! Download Google fonts only if launched in the browser -> Else use system fonts -->
<link href="https://fonts.googleapis.com/css?family=Open+Sans&display=swap" rel="stylesheet" media="all and (display-mode: browser)" />
styles.css
/* iOS */
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
/* Android */
font-family: 'RobotoRegular', 'Droid Sans', sans-serif;
/* Windows Phone */
font-family: 'Segoe UI', Segoe, Tahoma, Geneva, sans-serif;
Github example
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
Web Share API
- Copy to clipboard gist
- Web Share API button click event listener gist
HTML Streaming
- Creating an app shell
- Loading different parts of the website separately (navigation/app shell from the cache, content from server from NetworkFirst)
Navigation Preload
Workbox
workbox.navigationPreload.enable();
Vanilla
- Add this Inside the
'activate'
event:await self.registration.navigationPreload.enable();
TWA
APK Generators
- Llama Pack (by Google Chrome team)
- PWA Builder (by Microsoft)
Notifications
- TWAs need to have notification delegation enabled in order to get the permission by default
- Add this to the Android Manifest
<service android:name="com.google.androidbrowserhelper.trusted.DelegationService" android:enabled="@bool/enableNotification" android:exported="@bool/enableNotification"> <intent-filter> <action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE"/> <category android:name="android.intent.category.DEFAULT"/> </intent-filter> </service>
Web Perception API
Example and getting started guide
Tools & Resources
PWA Starter Kit
PWA Builder CLI
PWA and isomorphic rendering (SSR)
Progressive Web Components
PWA Technical Overview
Permissions site
Troubleshooting
Workbox v5
In v5, theglob
-related configuration options are no longer supported. The webpack asset pipeline is the source of all the automatically created manifest entries. (Developers who have files that exist outside of the webpack asset pipeline are encouraged to use, e.g.,copy-webpack-plugin
to get those files into the webpack compilation.)
manifestTransforms
can be used to make arbitrary changes to any aspect of the precache manifest, including adding entries, deleting them, and changing their revision or url fields as needed.- Example
const manifestTransform = (originalManifest, compilation) => { const warnings = []; const manifest = originalManifest.map((entry) => { // entry has size, revision, and url fields. // (alternatively, use modifyURLPrefix) if (entry.url.endsWith('.jpg')) { entry.url = `https://examplecdn.com/${entry.url}`; } // Remove revision when there's a match for your hashed URL pattern. // (alternatively, just set dontCacheBustURLsMatching) if (entry.url.match(/\.[0-9a-f]{6}\./)) { delete entry.revision; } // Exclude assets greater than 1MB, unless they're JPEGs. // (alternatively, use maximumFileSizeToCacheInBytes) if ((entry.size > 1024 * 1024) && !entry.url.endsWith('.jpg')) { warnings.push(`${entry.url} will not be precached because it is too big.`); return null; } return entry; }).filter(Boolean); // Exclude any null entries. // When manually adding in additional entries, make sure you use a URL // that already includes versioning info, like the v1.0.0 below: manifest.push({ url: 'https://examplecdn.com/third-party-code/v1.0.0/index.js', }); return {manifest, warnings}; };
- Example
- Your
swSrc
file in v4 might have looked likeprecacheAndRoute([]);
In v5, you should change this to
precacheAndRoute(self.__WB_MANIFEST);
Reading pre-cached files directly
it's still a best practice to call getCacheKeyForURL()
to determine the actual cache key, including the __WB_REVISION
parameter, if you need to access precached entries using the Cache Storage API directly.
import {cacheNames} from 'workbox-core';
import {getCacheKeyForURL} from 'workbox-precaching';
const cache = await caches.open(cacheNames.precache);
const response = await cache.match(
getCacheKeyForURL('/precached-file.html')
);
Alternatively, if all you need is the precached Response
object, you can call matchPrecache()
, which will automatically use the correct cache key and search in the correct cache:
import {matchPrecache} from 'workbox-precaching';
const response = await matchPrecache('/precached-file.html');
UI Fixes
Scroll into view when keyboard is shown on mobile
Element.scrollIntoView()
Common issues
Workbox new version
- When implementing the new version popup make sure that the tracked files have a revision string on the end of the request inside the cache by workbox.
/home.js?
WB_REVISION
=df42c4e7c6b43998e5ba
event.respondWith()
resulted in a network error response: an object that was not a Response was passed to respondWith().
event.respondWith((async () => { ... return await caches.match(event.request) || await fetch(event.request); ... // else return await caches.match(OFFLINE_PAGE_URL) || await fetch(OFFLINE_PAGE_URL, { method: 'GET' }); })());
Serving Cached Video files on Safari (Range requests)
Common Questions
- QUESTION: Can push permissions be enabled by default in a TWA?
- ANSWER: Yeap!
- From Chrome 75, the website for a TWA gets the notification permission enabled by default, provided the
enableNotifications
field inbuild.gradle
is set totrue
. If the user disables notifications for the TWA in Android Settings, this propagates through to Chrome, disabling notifications for the website. - Reference (bottom of comment)
- From Chrome 75, the website for a TWA gets the notification permission enabled by default, provided the
- ANSWER: Yeap!
- QUESTION: Is the previous cache deleted when a new service worker is installed (updated)?
- ANSWER: Yeap!
- QUESTION: If a user clicks on an ad and it opens in PWA will the
start_url
be overriden?- ANSWER: Yeap! - Web Manifest Specs
- User clicks on the PWA icon =>
start_url
from manifest is used, with the given parameters there.
- User searches on Google, clicks on an ad, URL of the ad matches the
intent_filters
in the PWA => The original URL of the ad is used and parameters fromstart_url
are overwritten.
- User searches on Google, clicks on an ad, URL of ad does NOT match the
intent_filters
in the PWA => PWA is not opened, but Chrome is opened with the original URL from the ad.
- User clicks on the PWA icon =>
- ANSWER: Yeap! - Web Manifest Specs
- QUESTION: Can I cache GraphQL POST requests?
- ANSWER: Absolutely! Just read this Medium article - Cache GraphQL POST requests with Service Worker