A presentation at PWA Summit 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
Trivago: 150% increase in use when added to the homescreen aka.ms/trivago-pwa-2017
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
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 What’s a PWA, technically? HTTPS Web App Manifest Service Worker
@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 What’s a PWA, technically? HTTPS Web App Manifest Service Worker
@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.
How’d it go? Any questions?
@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”: “standalone” “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
How’d it go? Any questions?
@AaronGustafson What’s a PWA, technically? HTTPS Web App Manifest Service Worker
@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 If screenshots are not universal { “src”: “/i/screenshots/main-ui.png”, “type”: “image/png”, “sizes”: “800x600”, “label”: “The main Squoosh window. Pick an image to get started.”, “platform”: “wide” }
@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
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
In this workshop, Aaron Gustafson will help you get your web projects super-charged as Progressive Web Apps (PWAs). The workshop will start off with an explanation of what PWAs are (and what they’re not), the use cases for and benefits of building them, and solid approaches to creating them. Then Aaron will walk you, step-by-step, through the process of turning a website into a PWA. Following along, you’ll author Web App Manifests and Service Workers and gain exposure to some next-gen APIs that tie them more deeply into the underlying operating system. By the end of the workshop, you’ll walk away with a functional PWA you can use right away.
The following resources were mentioned during the presentation or are useful additional information.
These are the project files you can work with if you don’t want to work on your own site.