Maxim Salnikov

@webmaxru

Diving deep, deep offline –

the web can do it!

What is an offline-ready web application

And how to build it today

Maxim Salnikov

  • Web Platform & Cloud speaker, writer, trainer

  • Developer communities facilitator

  • Technical conferences organizer

Developer Audience Lead at Microsoft

Web as an app platform

  • Historically depends on the "connection status"

  • Evergreen browsers

  • Versatile language

  • Performant JS engines

  • Excellent tooling

  • Huge community

Proper offline-ready web app

  • App itself

  • Online runtime data

  • Offline runtime data

  • Connection failures

  • Updates

  • Platform features

  • Always available

  • Thoughtfully collected

  • Safely preserved

  • Do not break the flow

  • Both explicit and implicit

  • For the win!

While keeping its web nature!

Web Almanac 2022

Application UI

Let's build an App shell

My App

  • Define assets

  • Put in the cache

  • Serve from the cache

  • Manage versions

}

Service worker

Logically

Physically

-file(s)

App

Service worker

Browser/OS

Event-driven worker

Cache

fetch
push
sync

In theory

self.addEventListener('install', event => {
    // Putting resources into the Cache Storage
})

self.addEventListener('activate', event => {
    // Managing versions
})

self.addEventListener('fetch', event => {
    // Exctracting from the cache and serving
})

handmade-service-worker.js

In the guide

const PRECACHE = 'precache-v1';
const RUNTIME = 'runtime';

const PRECACHE_URLS = [
  'index.html',
  './',
  'styles.css',
  '../../styles/main.css',
  'demo.js'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(PRECACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(self.skipWaiting())
  );
});

self.addEventListener('activate', event => {
  const currentCaches = [PRECACHE, RUNTIME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return cacheNames.filter(cacheName => !currentCaches.includes(cacheName));
    }).then(cachesToDelete => {
      return Promise.all(cachesToDelete.map(cacheToDelete => {
        return caches.delete(cacheToDelete);
      }));
    }).then(() => self.clients.claim())
  );
});

self.addEventListener('fetch', event => {
  if (event.request.url.startsWith(self.location.origin)) {
    if (event.request.url.indexOf('api/') != -1) {
      event.respondWith(
        caches.match(event.request.clone()).then((response) => {
          return response || fetch(event.request.clone()).then((r2) => {
            return caches.open(RUNTIME).then((cache) => {
              cache.put(event.request.url, r2.clone());
              return  r2.clone();
            });
          });
        })
      );
    } else {
      event.respondWith(
        caches.match(event.request).then(cachedResponse => {
          if (cachedResponse) {
            return cachedResponse;
          }
          return caches.open(RUNTIME).then(cache => {
            return fetch(event.request).then(response => {
              return cache.put(event.request, response.clone()).then(() => {
                return response;
              });
            });
          });
        })
      );
    }
  }
});

handmade-service-worker.js

− Build automation

− Configurability

− Extensibility

− Smart caching

− All possible fallbacks

− Communication with the app

− Debug information 

− ...

In production

Redirects?

Fallbacks?

Opaque response?

Versioning?

Cache invalidation?

Spec updates?

Cache storage space?

Variable asset names?

Feature detection?

Minimal required cache update?

Caching strategies?

Routing?

Fine-grained settings?

Kill switch?

I see the old version!!!

It's only partially a joke

  • Balanced abstraction level

  • Declarativeness where appropriate

  • Modularity and extensibility

  • Rich functionality out of the box

  • Powerful tooling 

~30% of the service workers — are...

Open source, active maintenance and support 

Implementing offline-readiness

import { precacheAndRoute } from "workbox-precaching";

// Cache and serve resources from __WB_MANIFEST array
precacheAndRoute(self.__WB_MANIFEST);

src/service-worker.js

Remaining steps:

  1. Populate __WB_MANIFEST

  2. Bundle

  3. Register in the application

 

# Using Workbox as a Node module
$ npm install workbox-build

}

On every app build

Build script

const { injectManifest } = require("workbox-build");

let workboxConfig = {
  swSrc: "src/service-worker.js",
  swDest: "dist/sw.js",
  globPatterns: ["index.html", "*.css", "*.js"]
};

injectManifest(workboxConfig).then(() => {
  console.log(`Generated ${workboxConfig.swDest}`);
});

sw-build.js

[Almost] ready service worker

import { precacheAndRoute } from "workbox-precaching";

precacheAndRoute([
  { revision: "866bcc582589b8920dbc", url: "index.html" },
  { revision: "c2761edff7776e1e48a3", url: "styles.css" },
  { revision: "3469613435532733abd9", url: "main.js" }
]);

dist/sw.js

import { precacheAndRoute } from "workbox-precaching";

precacheAndRoute(self.__WB_MANIFEST);

src/service-worker.js

App build integration

"build-pwa":
  "npm run build-app &&
   node build-sw.js &&
   npx rollup -c"

package.json / scripts

Registering in the app

import { Workbox, messageSW } from 'workbox-window';

if ('serviceWorker' in navigator) {
      const wb = new Workbox('/sw.js');      
      wb.register();
    
      // Interactive update flow with messageSW
      // code sample is at https://aka.ms/workbox6
}

src/main.js

New version is available. Click to reload.

Runtime application data

Intercepting requests

self.addEventListener('fetch', event => {

  if (event.request.url.indexOf('/api/breakingnews') != -1) {
    event.respondWith(
      // Network-First Strategy
    )
  } else if (event.request.url.indexOf('/api/archive') != -1 {
    event.respondWith(
      // Cache-First Strategy
    )
  }
})

handmade-service-worker.js

Runtime caching

import { registerRoute } from "workbox-routing";
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from "workbox-strategies";

src/service-worker.js

// Avatars can always be taken from the cache
registerRoute(
  new RegExp("https://www.gravatar.com/avatar/.*"),
  new CacheFirst()
);

API responses caching

// Keeping article list always fresh
registerRoute(
  ({url}) => url.pathname.startsWith('/api/articles/'),
  new NetworkFirst()
);

// Retrieving article from the cache and checking for updates
import { BroadcastUpdatePlugin } from 'workbox-broadcast-update';

registerRoute(
  ({url}) => url.pathname.startsWith('/api/article/'),
  new StaleWhileRevalidate({
    plugins: [
      new BroadcastUpdatePlugin()
    ],
  })
);

src/service-worker.js

Strategies

  • CacheFirst

  • CacheOnly

  • NetworkFirst

  • NetworkOnly

  • StaleWhileRevalidate

  • ...your own strategy?

Plugins

  • Expiration

  • CacheableResponse

  • BroadcastUpdate

  • BackgroundSync

  • ...your own plugin?

More platform tools

  • Background Fetch API

  • Native File System API

  • Badging API

  • Contact Picker API

  • Notification Triggers API

 + more than 100 other APIs

  • Full-fledged application platform

  • Offline-ready mechanisms are in production

  • Awesome tools are available

  • User experience & security is the key

And this is just the beginning!

Web platform today

  • Source code

  • Extra features

  • Demo hosted on Azure Static Web Apps

Extended PWA example

Thank you!

Maxim Salnikov

@webmaxru

Power App portals as PWAs

Microsoft 365 web apps as PWAs

Staying in sync

Background

Sync

No active app tab required

Periodic Background

Sync

One-off event coming when sync is possible

To periodically synchronize data 

async function postTweet(post) {
  const registration = await navigator.serviceWorker.ready;
  await savePost(post); // Storing data in IndexedDB
  await registration.sync.register('post-tweet');
}

main.js

self.addEventListener('sync', event => {
  if (event.tag == 'postTweet') {
    event.waitUntil(
        // Extract stored data and repeat sending
    );
  }
});

handmade-service-worker.js

import {BackgroundSyncPlugin} from 'workbox-background-sync';

const postTweetPlugin =
    new BackgroundSyncPlugin('tweetsQueue', {
        maxRetentionTime: 24 * 60 // Max retry period
    })

src/service-worker.js

registerRoute(
  ({url}) => url.pathname.startsWith('/api/post-tweet/'),
  new NetworkOnly({plugins: [postTweetPlugin]}),
  'POST'
)

...and host it for free in the cloud

Maxim Salnikov

@webmaxru

Taking your web app offline (in a good sense)

Questions?

Maxim Salnikov

@webmaxru

Diving deep, deep offline – the web can do it!

By Maxim Salnikov

Diving deep, deep offline – the web can do it!

There is no need to advocate for progressive web apps anymore. The idea of connection-independent applications has proven its viability and we see many projects following that path, making the offline-ready behavior a best practice, good manner of the web. In my session, based on the exploration of Service Worker API (Cache Storage, Background Fetch, Background Sync) we go through the history of the offline web, treating the network as an enhancement, current challenges, solutions, and proper tooling.

  • 3,112