Getting started with Progressive Web Apps Aaron Gustafson @AaronGustafson noti.st/AaronGustafson
A presentation at PWA Summit in October 2022 in by Aaron Gustafson
Getting started with Progressive Web Apps Aaron Gustafson @AaronGustafson noti.st/AaronGustafson
@AaronGustafson Follow along ๏ Slides: https://aka.ms/pwasummit-workshop-slides ๏ Work files: https://aka.ms/pwasummit-workshop-code ๏ Final Demo: https://aka.ms/pwasummit-workshop-live
What exactly is a PWA?
What exactly is a Progressive Web App?
What exactly is a Progressive Web App?
“Progressive Progressive Web App” App is a marketing term
Progressive Web App
Game Gallery Book Progressive Web App Newspaper Art Project Tool
Progressive Web Site
Who’s behind PWAs?
@AaronGustafson What’s a PWA, technically? HTTPS Web App Manifest Service Worker
Should I believe the hype?
Maybe?
Starbucks: 2x increase in daily active users desktop == mobile aka.ms/google-io-2018
Tinder: Core experience with 90% less code aka.ms/tinder-pwa-2017
Uber: Core PWA in 50k* Loads in under 3s over 2G * gzipped aka.ms/uber-pwa-2021
Forbes: 61% improvement in load time on mobile aka.ms/forbes-pwa-2021
Debenhams: 40% increase in mobile revenue 20% increase in conversion aka.ms/debenhams-pwa-2018
West Elm: 15% increase in time on site 9% increase in revenue per visit aka.ms/west-elm-pwa-2017
Flipkart: 50% of new customers via PWA 60% of PWA users uninstalled their app aka.ms/flipkart-2021
PWAs start with a great web experience and then enhance that experience for performance, resilience, installation, and engagement
Progressive Web App
Progressive Web App Enhancement
@AaronGustafson User Experience Enhance the experience Capabilities
Progressive /prəˈɡresiv/ happening or developing gradually or in stages; proceeding step by step
@AaronGustafson Let’s talk about HTTPS
@AaronGustafson HTTPS is simple (& free) now ๏ ๏ Many hosts include it ๏ GitHub ๏ Netlify ๏ AWS ๏ etc. LetsEnrcypt & Certbot for everything else https://letsencrypt.org/
@AaronGustafson Let’s talk about the Manifest
@AaronGustafson Manifest files are JSON files { “property_a”: “value”, “property_b”: [“value_1”, “value_2”], “property_c”: { “nested_property”: “nested_value” } }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “name”: “Aaron Gustafson”, “start_url”: “/” }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “name”: “Aaron Gustafson”, “start_url”: “/” }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “dir”: “ltr”, “name”: “Aaron Gustafson”, “start_url”: “/” }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “dir”: “auto”, // default “name”: “Aaron Gustafson”, “start_url”: “/” }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “name”: “Aaron Gustafson”, “start_url”: “/” }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “name”: “Aaron Gustafson”, “start_url”: “/” }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “name”: “Aaron Gustafson”, “short_name”: “AaronG”, “start_url”: “/” }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “name”: “Aaron Gustafson”, “short_name”: “AaronG”, “start_url”: “/” }
@AaronGustafson Minimum Viable Manifest { “lang”: “en-US”, “name”: “Aaron Gustafson”, “short_name”: “AaronG”, “start_url”: “/” }
@AaronGustafson Reference in the head <link rel=”manifest” href=”/manifest.json”>
@AaronGustafson Reference in the head <link rel=”manifest” href=”/manifest.json”>
@AaronGustafson Reference in the head <link rel=”manifest” href=”/manifest.json”> t s e f i n a m b e w . e m a n _ p p e a b o s l a d l u o w ) r a l i m i E s M I (or M s e r i u q e r t u b , e l b a t n p e o s c j + ac t s e f i n a m / n o i t a c i l p ap
@AaronGustafson Let‘s make a manifest! ๏ Open a new text document in your site and name it manifest.json ๏ Create a basic JSON object inside, including the following Manifest members: 1. lang (and, optionally, dir), 2. name, 3. short_name (if you need it), and 4. start_url.
@AaronGustafson Let’s prep for install… { “lang”: “en-US”, “name”: “Aaron Gustafson”, “short_name”: “AaronG”, “start_url”: “/” }
@AaronGustafson Start at the beginning { … “start_url”: “/” }
@AaronGustafson Where does this apply? { … “start_url”: “/”, “scope”: “/” // defaults to the start_url path }
@AaronGustafson What should it look like? { … “start_url”: “/”, “display”: “minimal-ui” }
@AaronGustafson Display Modes “display”: “browser” “display”: “fullscreen” “standalone” “display”: “minimal-ui”
@AaronGustafson Need to lock orientation? { … “start_url”: “/”, “display”: “minimal-ui”, “orientation”: “any” }
@AaronGustafson Orientation options ๏ “any “– no preference ๏ “natural” – the default orientation of the device ๏ “portrait” ๏ ๏ “portrait-primary” ๏ “portrait-secondary” “landscape” ๏ “landscape-primary” ๏ “landscape-secondary” Details: https://www.w3.org/TR/screen-orientation/
@AaronGustafson A little color… { … “start_url”: “/”, “display”: “minimal-ui”, “orientation”: “any”, “theme_color”: “#27831B” }
@AaronGustafson A little color… { … “start_url”: “/”, “display”: “minimal-ui”, “orientation”: “any”, “theme_color”: “#27831B”, “background_color”: “#fffcf4” }
@AaronGustafson Adding icons { … “icons”: [ { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600” }, { “src”: “/i/notification-icon.png”, “type”: “image/png”, “sizes”: “256x256” }, { “src”: “/favicon.png”, “type”: “image/png”, “sizes”: “16x16” } ] }
@AaronGustafson Adding icons { … “icons”: [ { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600” }, { “src”: “/i/notification-icon.png”, “type”: “image/png”, “sizes”: “256x256” }, { “src”: “/favicon.png”, “type”: “image/png”, “sizes”: “16x16” } ] }
@AaronGustafson Anatomy of an ImageResource { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600” }
@AaronGustafson Anatomy of an ImageResource { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600” }
@AaronGustafson Anatomy of an ImageResource { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600” }
@AaronGustafson Anatomy of an ImageResource { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600” }
@AaronGustafson Anatomy of an ImageResource { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600” }
@AaronGustafson Anatomy of an ImageResource { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600”, “purpose”: } S O e h t l l i e f l b “monochrome” d i a l n o e s o a t r t e n v a o ) w o e g g u o d o l a y r b f u I a o y r o k f s a s a m h c to (su
@AaronGustafson Anatomy of an ImageResource { “src”: “/i/og-logo.png”, “type”: “image/png”, “sizes”: “800x600”, “purpose”: “maskable” } d e n g i s e d d n s i a s e k g s a a im m d n n i o c m i n h i t i e w n o z e f a s
@AaronGustafson Let‘s improve our manifest! ๏ Add the following Manifest members: 1. display, 2. theme_color, 3. background_color, and 4. icons Suggested sizes: 48x48, 72x72, 96x96, 144x144, 192x192, and 512x512
@AaronGustafson User Experience Enhance the experience Capabilities
@AaronGustafson User Experience Enhance the experience Capabilities
@AaronGustafson Let’s talk about Service Worker
@AaronGustafson Registering a Service Worker if ( “serviceWorker” in navigator ) { navigator.serviceWorker.register( “/serviceworker.min.js” ); }
@AaronGustafson Registering a Service Worker if ( “serviceWorker” in navigator ) { navigator.serviceWorker.register( “/serviceworker.min.js” ); }
@AaronGustafson Registering a Service Worker if ( “serviceWorker” in navigator ) { navigator.serviceWorker.register( “/serviceworker.min.js” ); }
@AaronGustafson Registering a Service Worker if ( “serviceWorker” in navigator ) { navigator.serviceWorker.register( “/serviceworker.min.js” ); } s i h t Pa o p im ! t n a rt
@AaronGustafson Registering a Service Worker if ( “serviceWorker” in navigator ) { navigator.serviceWorker.register( “/serviceworker.min.js” ) .then(function( registration ){ console.log( “Success!”, registration.scope ); }) .catch(function( error ){ console.error( “Failure!” , error ); }); }
@AaronGustafson User Experience Enhance the experience Capabilities
@AaronGustafson The Service Worker Lifecycle Browser Install Activation aka.ms/pwa-lifecycle Ready
@AaronGustafson Listening for these events self.addEventListener( “install”, function( event ){ console.log( “installing” ); });
@AaronGustafson Listening for these events self.addEventListener( “install”, function( event ){ console.log( “installing” ); });
@AaronGustafson Listening for these events self.addEventListener( “install”, function( event ){ console.log( “installing” ); });
@AaronGustafson Listening for these events self.addEventListener( “install”, function( event ){ console.log( “installing” ); });
@AaronGustafson Let‘s make a Service Worker! ๏ Create a new file for your service worker ๏ Register the Service Worker navigator.serviceWorker.register( path ) ๏ Log to the console from the following events: ๏ install ๏ activate
@AaronGustafson Yours should look similar self.addEventListener( “install”, function( event ){ console.log( “installing” ); }); self.addEventListener( “activate”, function( event ){ console.log( “activating” ); });
@AaronGustafson The Service Worker Lifecycle Browser Install Activation aka.ms/pwa-lifecycle Ready
@AaronGustafson Preloading assets self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open(“v1”).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js” ]); }) ); });
@AaronGustafson Preloading assets self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open(“v1”).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js” ]); }) ); });
@AaronGustafson Preloading assets self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open(“v1”).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js” ]); }) ); });
@AaronGustafson Preloading assets self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open(“v1”).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js” ]); }) ); });
@AaronGustafson Preloading assets self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open(“v1”).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js” ]); }) ); });
@AaronGustafson Let’s refactor self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open(“v1”).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js” ]); }) ); });
@AaronGustafson Let’s refactor const VERSION = “v1”; self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open( VERSION ).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js” ]); }) ); });
@AaronGustafson Let‘s preload assets ๏ Leverage the install event to pre-load some assets ๏ Load your page in a browser and see that the assets are loaded ๏ Bump your version number and reload the page ๏ What happened?
@AaronGustafson Clean up after yourself const VERSION = “v1”; // install event self.addEventListener( “activate”, event => { // clean up stale caches event.waitUntil( caches.keys() .then( keys => { return Promise.all( keys.filter( key => { return ! key.startsWith( VERSION ); }) .map( key => { return caches.delete( key ); }) ); }) ); });
@AaronGustafson Clean up after yourself const VERSION = “v1”; // install event self.addEventListener( “activate”, event => { // clean up stale caches event.waitUntil( caches.keys() .then( keys => { return Promise.all( keys.filter( key => { return ! key.startsWith( VERSION ); }) .map( key => { return caches.delete( key ); }) ); }) ); });
@AaronGustafson Clean up after yourself const VERSION = “v1”; // install event self.addEventListener( “activate”, event => { // clean up stale caches event.waitUntil( caches.keys() .then( keys => { return Promise.all( keys.filter( key => { return ! key.startsWith( VERSION ); }) .map( key => { return caches.delete( key ); }) ); }) ); });
@AaronGustafson Clean up after yourself const VERSION = “v1”; // install event self.addEventListener( “activate”, event => { // clean up stale caches event.waitUntil( caches.keys() .then( keys => { return Promise.all( keys.filter( key => { return ! key.startsWith( VERSION ); }) .map( key => { return caches.delete( key ); }) ); }) ); });
@AaronGustafson Clean up after yourself const VERSION = “v1”; // install event self.addEventListener( “activate”, event => { // clean up stale caches event.waitUntil( caches.keys() .then( keys => { return Promise.all( keys.filter( key => { return ! key.startsWith( VERSION ); }) .map( key => { return caches.delete( key ); }) ); }) ); });
@AaronGustafson Clean up after yourself const VERSION = “v1”; // install event self.addEventListener( “activate”, event => { // clean up stale caches event.waitUntil( caches.keys() .then( keys => { return Promise.all( keys.filter( key => { return ! key.startsWith( VERSION ); }) .map( key => { return caches.delete( key ); }) ); }) ); });
@AaronGustafson Let‘s clean up ๏ Leverage the activate event to clear stale caches ๏ Load your page in a browser ๏ What happened?
@AaronGustafson Use the latest SW immediately const VERSION = “v1”; self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open( VERSION ).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js” ]); }) ); self.skipWaiting(); });
@AaronGustafson Claim any active clients self.addEventListener( “activate”, event => { // clean up stale caches event.waitUntil( caches.keys() .then( keys => { return Promise.all( keys.filter( key => { return ! key.startsWith( VERSION ); }) .map( key => { return caches.delete( key ); }) ); }) ); clients.claim(); });
@AaronGustafson Look what happens ๏ ๏ Add skipWaiting() to your install event ๏ Look at the DevTools and observe how the state of the Service Worker changes with and without this line of code. ๏ What happened? Open your site in two tabs and add clients.claim() to your activate event ๏ Look at the DevTools in each. ๏ What happened?
@AaronGustafson Talk about the network
@AaronGustafson How requests are made Browser Internet
@AaronGustafson Along comes Service Worker Browser Internet
@AaronGustafson Along comes Service Worker Browser Cache Internet
@AaronGustafson Along comes Service Worker Browser ! Cache Internet
@AaronGustafson Intercepting requests self.addEventListener( “fetch”, function( event ){ console.log( “fetching” ); });
@AaronGustafson Let‘s try it out ๏ ๏ Add a fetch event handler ๏ Load your page in a browser ๏ What happened? Instead of a string, log event.request.url ๏ Load your page in a browser ๏ What do you see?
@AaronGustafson We can issue our own fetch self.addEventListener( “fetch”, function( event ){ event.respondWith( fetch( event.request ) ); });
@AaronGustafson We can issue our own fetch self.addEventListener( “fetch”, function( event ){ event.respondWith( fetch( event.request ) ); });
What if the request fails?
@AaronGustafson What if the request fails? const VERSION = “v1”, OFFLINE_PAGE = “offline.html”;
@AaronGustafson What if the request fails? self.addEventListener( “install”, function( event ){ event.waitUntil( caches.open(“v1”).then(function(cache) { return cache.addAll([ “/css/main.css”, “/js/main.js”, OFFLINE_PAGE ]); }) ); });
@AaronGustafson What if the request fails? self.addEventListener( “fetch”, function( event ){ if ( event.request.mode === “navigate” ) { event.respondWith( fetch(event.request) .catch(error => { console.log( “Fetch failed; returning offline page.” ); return caches.match( OFFLINE_PAGE ); }) ); } });
@AaronGustafson What if the request fails? self.addEventListener( “fetch”, function( event ){ if ( event.request.mode === “navigate” ) { event.respondWith( fetch(event.request) .catch(error => { console.log( “Fetch failed; returning offline page.” ); return caches.match( OFFLINE_PAGE ); }) ); } });
@AaronGustafson What if the request fails? self.addEventListener( “fetch”, function( event ){ if ( event.request.mode === “navigate” ) { event.respondWith( fetch(event.request) .catch(error => { console.log( “Fetch failed; returning offline page.” ); return caches.match( OFFLINE_PAGE ); }) ); } });
@AaronGustafson What if the request fails? self.addEventListener( “fetch”, function( event ){ if ( event.request.mode === “navigate” ) { event.respondWith( fetch(event.request) .catch(error => { console.log( “Fetch failed; returning offline page.” ); return caches.match( OFFLINE_PAGE ); }) ); } });
@AaronGustafson Let‘s try it out ๏ Add an offline.html page ๏ Pre-cache it during install ๏ Remember to rev VERSION ๏ Add a fetch handler for navigations, providing the offline page as a fallback ๏ Turn off the network (once you know the SW is running) and see what happens
@AaronGustafson Caching strategies ๏ Network → cache → offline ๏ Cache → network → offline ๏ Cache vs network race → cache → offline ๏ etc.
@AaronGustafson Network, then cache if ( event.request.mode === “navigate” ) { event.respondWith( fetch( event.request ) .catch(error => { return caches.match( OFFLINE_PAGE ); }) ); }
@AaronGustafson Network, then cache self.addEventListener( “fetch”, function( event ){ let request = event.request, url = request.url; // all the rest of the code }
@AaronGustafson Network, then cache if ( event.request.mode === “navigate” ) { event.respondWith( fetch( event.request ) .catch(error => { return caches.match( OFFLINE_PAGE ); }) ); }
@AaronGustafson Network, then cache if ( request.mode === “navigate” ) { event.respondWith( fetch( request ) .catch(error => { return caches.match( OFFLINE_PAGE ); }) ); }
@AaronGustafson Network, then cache if ( request.mode === “navigate” ) { event.respondWith( fetch( request ) .then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, response ); }) ); return response.clone(); }) .catch(error => { return caches.match( OFFLINE_PAGE ); }) ); }
@AaronGustafson Network, then cache if ( request.mode === “navigate” ) { event.respondWith( fetch( request ) .then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, response ); }) ); return response.clone(); }) .catch(error => { return caches.match( OFFLINE_PAGE ); }) ); }
@AaronGustafson Network, then cache if ( request.mode === “navigate” ) { event.respondWith( fetch( request ) .then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, response ); }) ); return response.clone(); }) .catch(error => { return caches.match( OFFLINE_PAGE ); }) ); }
@AaronGustafson Network, then cache if ( request.mode === “navigate” ) { event.respondWith( fetch( request ) .then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, response ); }) ); return response.clone(); }) .catch(error => { return caches.match( OFFLINE_PAGE ); }) ); }
@AaronGustafson Network, then cache .catch(error => { return caches.match( OFFLINE_PAGE ); })
@AaronGustafson Network, then cache .catch(error => { return caches.match( request ).then( cached_result => { if ( cached_result ) { return cached_result; } return caches.match( OFFLINE_PAGE ); }); })
@AaronGustafson Network, then cache .catch(error => { return caches.match( request ).then( cached_result => { if ( cached_result ) { return cached_result; } return caches.match( OFFLINE_PAGE ); }); })
@AaronGustafson Network, then cache .catch(error => { return caches.match( request ).then( cached_result => { if ( cached_result ) { return cached_result; } return caches.match( OFFLINE_PAGE ); }); })
@AaronGustafson Network, then cache if ( request.mode === “navigate” ) { event.respondWith( fetch( request ).then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, response ); }) ); return response.clone(); }) .catch(error => { return caches.match( request ).then( cached_result => { if ( cached_result ) { return cached_result; } return caches.match( OFFLINE_PAGE ); }); }) ); }
@AaronGustafson Caching strategies ✓ Network → cache → offline ๏ Cache → network → offline ๏ Cache vs network race → cache → offline ๏ etc.
@AaronGustafson Cache first, then network if ( /.css$/.test(url) || /.js$/.test(url) ) { event.respondWith( caches.match( request ) .then( cached_result => { if ( cached_result ) { return cached_result; } return fetch( request ).then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, reponse ); }) ); return response.clone(); }) .catch( new Response( “”, { status: 408, statusText: “The server appears to be offline.” }) ); }) ); }
@AaronGustafson Cache first, then network if ( /.css$/.test(url) || /.js$/.test(url) ) { event.respondWith( caches.match( request ) .then( cached_result => { if ( cached_result ) { return cached_result; } return fetch( request ).then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, reponse ); }) ); return response.clone(); }) .catch( new Response( “”, { status: 408, statusText: “The server appears to be offline.” }) ); }) ); }
@AaronGustafson Cache first, then network if ( /.css$/.test(url) || /.js$/.test(url) ) { event.respondWith( caches.match( request ) .then( cached_result => { if ( cached_result ) { return cached_result; } return fetch( request ).then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, reponse ); }) ); return response.clone(); }) .catch( new Response( “”, { status: 408, statusText: “The server appears to be offline.” }) ); }) ); }
@AaronGustafson Cache first, then network if ( /.css$/.test(url) || /.js$/.test(url) ) { event.respondWith( caches.match( request ) .then( cached_result => { if ( cached_result ) { return cached_result; } return fetch( request ).then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, reponse ); }) ); return response.clone(); }) .catch( new Response( “”, { status: 408, statusText: “The server appears to be offline.” }) ); }) ); }
@AaronGustafson Cache first, then network if ( /.css$/.test(url) || /.js$/.test(url) ) { event.respondWith( caches.match( request ) .then( cached_result => { if ( cached_result ) { return cached_result; } return fetch( request ).then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, response ); }) ); return response.clone(); }) .catch( new Response( “”, { status: 408, statusText: “The server appears to be offline.” }) ); }) ); }
@AaronGustafson Cache first, then network if ( /.css$/.test(url) || /.js$/.test(url) ) { event.respondWith( caches.match( request ) .then( cached_result => { if ( cached_result ) { return cached_result; } return fetch( request ).then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, reponse ); }) ); return response.clone(); }) .catch( new Response( “”, { status: 408, statusText: “The server appears to be offline.” }) ); }) ); }
Anyone see an opportunity for refactoring?
@AaronGustafson We cached a fetch twice return fetch( request ).then( response => { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, response ); }) ); return response.clone(); })
@AaronGustafson Let’s make it a function function cacheResponse( response ) { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( request, response ); }) ); return response.clone(); }
@AaronGustafson Let’s make it a function function cacheResponse( response, event ) { event.waitUntil( caches.open( VERSION ).then( cache => { return cache.put( event.request, response ); }) ); return response.clone(); }
@AaronGustafson And we can use it like this return fetch( request ) .then( response => cacheResponse( response, event ) )
@AaronGustafson Network, then cache if ( request.mode === “navigate” ) { event.respondWith( fetch( request ) .then( response => cacheResponse( response, event ) ) .catch(error => { return caches.match( request ).then( cached_result => { if ( cached_result ) { return cached_result; } return caches.match( OFFLINE_PAGE ); }); }) ); }
@AaronGustafson Cache first, then network if ( /.css$/.test(url) || /.js$/.test(url) ) { event.respondWith( caches.match( request ) .then( cached_result => { if ( cached_result ) { return cached_result; } return fetch( request ) .then( response => cacheResponse( response, event ) ) .catch( new Response( “”, { status: 408, statusText: “The server appears to be offline.” }) ); }) ); }
@AaronGustafson Caching strategies ✓ Network → cache → offline ✓ Cache → network → offline ๏ Cache vs network race → cache → offline ๏ etc.
@AaronGustafson Who will win? document.addEventListener(‘DOMContentLoaded’, function(event) { var networkDone = false; var networkRequest = fetch(‘weather.json’).then(function(response) { return response.json(); }) .then(function(json) { networkDone = true; updatePage(json); }); caches.match(‘weather.json’).then(function(response) { if ( ! response) throw Error(‘No data’); return response.json(); }) .then(function(json) { if (!networkDone) updatePage(json); }) .catch(function() { return networkRequest; }) .catch(function() { console.log(‘We have nothing.’); }) .then(hideLoading); }); https://git.io/v56s4
@AaronGustafson Who will win? document.addEventListener(‘DOMContentLoaded’, function(event) { var networkDone = false; var networkRequest = fetch(‘weather.json’).then(function(response) { return response.json(); }) .then(function(json) { networkDone = true; updatePage(json); }); caches.match(‘weather.json’).then(function(response) { if ( ! response) throw Error(‘No data’); return response.json(); }) .then(function(json) { if (!networkDone) updatePage(json); }) .catch(function() { return networkRequest; }) .catch(function() { console.log(‘We have nothing.’); }) .then(hideLoading); }); https://git.io/v56s4
@AaronGustafson Who will win? document.addEventListener(‘DOMContentLoaded’, function(event) { var networkDone = false; var networkRequest = fetch(‘weather.json’).then(function(response) { return response.json(); }) .then(function(json) { networkDone = true; updatePage(json); }); caches.match(‘weather.json’).then(function(response) { if ( ! response) throw Error(‘No data’); return response.json(); }) .then(function(json) { if (!networkDone) updatePage(json); }) .catch(function() { return networkRequest; }) .catch(function() { console.log(‘We have nothing.’); }) .then(hideLoading); }); https://git.io/v56s4
@AaronGustafson Who will win? document.addEventListener(‘DOMContentLoaded’, function(event) { var networkDone = false; var networkRequest = fetch(‘weather.json’).then(function(response) { return response.json(); }) .then(function(json) { networkDone = true; updatePage(json); }); caches.match(‘weather.json’).then(function(response) { if ( ! response) throw Error(‘No data’); return response.json(); }) .then(function(json) { if (!networkDone) updatePage(json); }) .catch(function() { return networkRequest; }) .catch(function() { console.log(‘We have nothing.’); }) .then(hideLoading); }); https://git.io/v56s4
@AaronGustafson Let‘s discuss ๏ In what scenarios would these different caching strategies be most appropriate? ๏ Are there other strategies you’d like to discuss? ๏ Do you want to add these caching strategies to your site now?
Could we save data?
@AaronGustafson Look at the connection let slow_connection = false, save_data = false; function testConnection() { // only test every minute if ( last_tested && Date.now() < last_tested + ( 60 * 1000 ) ) { return; } if ( ‘connection’ in navigator ) { slow_connection = ( navigator.connection.downlink < 0.5 ); save_data = navigator.connection.saveData; last_tested = Date.now(); } }
@AaronGustafson Look at the connection let slow_connection = false, save_data = false; function testConnection() { // only test every minute if ( last_tested && Date.now() < last_tested + ( 60 * 1000 ) ) { return; } if ( ‘connection’ in navigator ) { slow_connection = ( navigator.connection.downlink < 0.5 ); save_data = navigator.connection.saveData; last_tested = Date.now(); } }
@AaronGustafson Look at the connection let slow_connection = false, save_data = false; function testConnection() { // only test every minute if ( last_tested && Date.now() < last_tested + ( 60 * 1000 ) ) { return; } if ( ‘connection’ in navigator ) { slow_connection = ( navigator.connection.downlink < 0.5 ); save_data = navigator.connection.saveData; last_tested = Date.now(); } }
@AaronGustafson Look at the connection let slow_connection = false, save_data = false; function testConnection() { // only test every minute if ( last_tested && Date.now() < last_tested + ( 60 * 1000 ) ) { return; } if ( ‘connection’ in navigator ) { slow_connection = ( navigator.connection.downlink < 0.5 ); save_data = navigator.connection.saveData; last_tested = Date.now(); } }
@AaronGustafson Look at the connection self.addEventListener( “fetch”, function( event ){ testConnection(); let request = event.request, url = request.url; … });
@AaronGustafson Use that information else if ( request.headers.get(“Accept”).includes(“image”) ) { event.respondWith( caches.match( request ).then( cached_result => { // cached first if ( cached_result ) { return cached_result; } // fallback to network if ( ! slow_connection && ! save_data ) { return fetch( request ) .then( response => cacheResponse( response, event ) ) // fail .catch( // Respond with an “offline” image ); } else { // Respond with a “saving data” image } }) ); }
@AaronGustafson Use that information else if ( request.headers.get(“Accept”).includes(“image”) ) { event.respondWith( caches.match( request ).then( cached_result => { // cached first if ( cached_result ) { return cached_result; } // fallback to network if ( ! slow_connection && ! save_data ) { return fetch( request ) .then( response => cacheResponse( response, event ) ) // fail .catch( // Respond with an “offline” image ); } else { // Respond with a “saving data” image } }) ); }
@AaronGustafson Use that information else if ( request.headers.get(“Accept”).includes(“image”) ) { event.respondWith( caches.match( request ).then( cached_result => { // cached first if ( cached_result ) { return cached_result; } // fallback to network if ( ! slow_connection && ! save_data ) { return fetch( request ) .then( response => cacheResponse( response, event ) ) // fail .catch( // Respond with an “offline” image ); } else { // Respond with a “saving data” image } }) ); }
@AaronGustafson Use that information else if ( request.headers.get(“Accept”).includes(“image”) ) { event.respondWith( caches.match( request ).then( cached_result => { // cached first if ( cached_result ) { return cached_result; } // fallback to network if ( ! slow_connection && ! save_data ) { return fetch( request ) .then( response => cacheResponse( response, event ) ) // fail .catch( // Respond with an “offline” image ); } else { // Respond with a “saving data” image } }) ); }
@AaronGustafson Use that information else if ( request.headers.get(“Accept”).includes(“image”) ) { event.respondWith( caches.match( request ).then( cached_result => { // cached first if ( cached_result ) { return cached_result; } // fallback to network if ( ! slow_connection && ! save_data ) { return fetch( request ) .then( response => cacheResponse( response, event ) ) // fail .catch( // Respond with an “offline” image ); } else { // Respond with a “saving data” image } }) ); }
@AaronGustafson Use that information else if ( request.headers.get(“Accept”).includes(“image”) ) { event.respondWith( caches.match( request ).then( cached_result => { // cached first if ( cached_result ) { return cached_result; } // fallback to network if ( ! slow_connection && ! save_data ) { return fetch( request ) .then( response => cacheResponse( response, event ) ) // fail .catch( // Respond with an “offline” image ); } else { // Respond with a “saving data” image } }) ); }
@AaronGustafson Dynamic images? Yes please! const VERSION = “v2”, OFFLINE_PAGE = “offline.html”, SVG_OFFLINE SVG_SLOW = ‘<svg …></svg>’, = ‘<svg …></svg>’;
@AaronGustafson Dynamic images? Yes please! function newSVGResponse( svg ) { return new Response( svg, { headers: { ‘Content-Type’: ‘image/svg+xml’ } }); }
@AaronGustafson An SVG for your troubles else if ( request.headers.get(“Accept”).includes(“image”) ) { event.respondWith( caches.match( request ).then( cached_result => { // cached first if ( cached_result ) { return cached_result; } // fallback to network if ( ! slow_connection && ! save_data ) { return fetch( request ) .then( response => cacheResponse( response, event ) ) // fail .catch( // Respond with an “offline” image ); } else { // Respond with a “saving data” image } }) ); }
@AaronGustafson An SVG for your troubles else if ( request.headers.get(“Accept”).includes(“image”) ) { event.respondWith( caches.match( request ).then( cached_result => { // cached first if ( cached_result ) { return cached_result; } // fallback to network if ( ! slow_connection && ! save_data ) { return fetch( request ) .then( response => cacheResponse( response, event ) ) // fail .catch( () => newSVGResponse( SVG_OFFLINE ) ); } else { return newSVGResponse( SVG_SLOW ); } }) ); }
@AaronGustafson Let‘s discuss ๏ What other ways we could use Service Workers to improve the user experience?
@AaronGustafson User Experience Moar enhancements! Capabilities
@AaronGustafson Bonus content! ๏ Rich Installation / Store Pages ๏ Shortcuts ๏ Share Target
@AaronGustafson Bonus content! ๏ Rich Installation / Store Pages ๏ Shortcuts ๏ Share Target
@AaronGustafson Marketing your PWA
@AaronGustafson Name / Short Name
@AaronGustafson Icons
@AaronGustafson Description
@AaronGustafson Description { “name”: “Squoosh”, “icons”: [ … ], “description”: “Make images smaller using best-in-class…”, “screenshots”: [ … ] }
@AaronGustafson Screenshots
@AaronGustafson Screenshots { “name”: “Squoosh”, “icons”: [ … ], “description”: “Make images smaller using best-in-class…”, “screenshots”: [ … ] }
@AaronGustafson Screenshots need labels { “src”: “/i/screenshots/main-ui.png”, “type”: “image/png”, “sizes”: “800x600”, “label”: “The main Squoosh window. Pick an image to get started.” }
@AaronGustafson Manifest App Information ๏ description ๏ screenshots ๏ categories ๏ iarc_rating_id ๏ Under consideration: ๏ Developer/Publisher ๏ Policies ๏ Banner Graphic ๏ Change Log
@AaronGustafson Bonus content! ๏ Rich Installation / Store Pages ๏ Shortcuts ๏ Share Target
@AaronGustafson Quick access to key tasks { … “shortcuts”: [ … ] }
@AaronGustafson Anatomy of a Shortcut Item { “name”: “New Tweet”, “url”: “/compose/tweet”, “icons”: [{ “src”: “icon-compose.png”, “type”: “image/png”, “sizes”: “192x192” }] }
@AaronGustafson Anatomy of a Shortcut Item { “name”: “New Tweet”, “url”: “/compose/tweet”, “icons”: [{ “src”: “icon-compose.png”, “type”: “image/png”, “sizes”: “192x192” }] }
@AaronGustafson Anatomy of a Shortcut Item { “name”: “New Tweet”, “url”: “/compose/tweet”, “icons”: [{ “src”: “icon-compose.png”, “type”: “image/png”, “sizes”: “192x192” }] }
@AaronGustafson Bonus content! ๏ Rich Installation / Store Pages ๏ Shortcuts ๏ Share Target
@AaronGustafson Easy sharing to your app { … “share_target”: {} }
@AaronGustafson Anatomy of a Share Target “share_target”: { “action”: “linky/poo/”, “method”: “GET”, “enctype”: “application/x-www-form-urlencoded”, “params”: { “title”: “title”, “text”: “body”, “url”: “url” } }
@AaronGustafson Anatomy of a Share Target “share_target”: { “action”: “linky/poo/”, “method”: “GET”, “enctype”: “application/x-www-form-urlencoded”, “params”: { “title”: “title”, “text”: “body”, “url”: “url” } }
@AaronGustafson Anatomy of a Share Target “share_target”: { “action”: “linky/poo/”, “method”: “GET”, “enctype”: “application/x-www-form-urlencoded”, “params”: { “title”: “title”, “text”: “body”, “url”: “url” } }
@AaronGustafson Anatomy of a Share Target “share_target”: { “action”: “linky/poo/”, “method”: “GET”, “enctype”: “application/x-www-form-urlencoded”, “params”: { “title”: “title”, “text”: “body”, “url”: “url” } }
@AaronGustafson You can also accept files “params”: { “text”: “text”, “url”: “url”, “files”: [{ “name”: “externalMedia”, “accept”: [ “image/jpeg”, “image/png”, “image/gif”, “video/quicktime”, “video/mp4” ] }]
PWAs start with a great web experience and then enhance that experience for performance, resilience, installation, and engagement
@AaronGustafson Congrats, you made a PWA! HTTPS Web App Manifest Service Worker
Thank you! @AaronGustafson aaron-gustafson.com noti.st/AaronGustafson