How to Build an Offline-First Progressive Web App

In this guide, we will build a Progressive Web App (PWA) that integrates PSPDFKit for Web and ticks the majority of the boxes of the Baseline PWA checklist. We’ve already built a more advanced implementation of this example, which you can try live on our website.

If you have never heard about PWAs and want to find out more about them, we highly recommend you check out the dedicated site from Google. You can also read our blog post on PWAs.

Requirements

Before you start, make sure you have Node.js and npm installed.

You will also need to obtain a copy of PSPDFKit for Web and a license key. Please refer to our Adding to Your Project guide for installation instructions.

Create a new folder for your project and initialize a new npm project:

1
2
3
4
mkdir pwa-example
cd pwa-example
npm init -y
mkdir src

Our PWA must be served by a web server over HTTPS, so please make sure your server is configured to serve pages in HTTPS. In development, however, we don’t need HTTPS since browsers whitelist localhost.

Let’s install a simple web server:

1
npm install serve

The Application Shell

PWAs build upon a minimal application foundation that is the application shell or app shell.

An app shell is the minimal HTML, CSS, and JavaScript code needed to make the application immediately functional and interactive. Usually it is a simple index.html entry file that loads quickly and is cached right away. Subsequent launches of the applications load instantly since the shell is served from the local device’s cache.

Let’s create a simple application shell that includes minimal markup and CSS:

./src/index.html
Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
    />
    <title>PSPDFKit PWA</title>

    <link rel="stylesheet" href="./index.css">
  </head>

  <body>
    <h1>PSPDFKit PWA</h1>
    <div class="App">
      <div class="App-actions">
        <input type="file" name="pdf-picker" accept="application/pdf" aria-label="File Picker: select a local PDF file">
      </div>
      <main class="App-main">
        <div class="PSPDFKit-container"></div>
      </main>
    </div>
  </body>
</html>

./src/index.css
Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
* {
  box-sizing: border-box;
}
body {
  margin: 0;
  font-family: sans-serif;
}
.App {
  display: flex;
  flex-direction: column;
  height: 100vh;
}
.App-main {
  margin-top: 1em;
  flex: 1;
  background: #eee;
  position: relative;
}
.PSPDFKit-container {
  position: absolute;
  width: 100%;
  height: 100%; /* PSPDFKit for Web container needs to define a height. */
}
.PSPDFKit-container:empty:before {
  content: "This is a placeholder for PSPDFKit for Web. Please select a file to load the PDF viewer.";
  display: block;
  padding: 1em;
}

Adding PSPDFKit for Web

Install PSPDFKit for Web using the tarball URL, which you can find in the customer portal. For trial licenses, please follow the instructions in the registration email:

1
npm install https://customers.pspdfkit.com/npm/YOUR_NPM_KEY_GOES_HERE/latest.tar.gz

Let’s copy the PSPDFKit files to a vendor folder:

1
2
mkdir vendor
cp -R node_modules/pspdfkit/dist/* vendor

Now we include pspdfkit.js in our app shell:

Copy
1
2
3
4
5
6
7
8
9
10
<!-- ./src/index.html -->

      <main class="App-main">
        <div class="PSPDFKit-container"></div>
      </main>
    </div>

+    <script src="./vendor/pspdfkit.js"></script>
  </body>
</html>

License Key

We also need to save our PSPDFKit for Web license in the ./src/license-key file. You can find the instructions to retrieve your license key in the Adding to Your Project guide.

Opening a PDF file

Let’s create a ./src/app.js file and include it in our app shell:

1
2
3
4
5
6
<!-- ./src/index.html -->

    <script src="./vendor/pspdfkit.js"></script>
+    <script src="./app.js"></script>
  </body>
</html>

This is our main application that will wire the HTML, the file picker, and PSPDFKit for Web.

We will need a helper to read the selected file from disk using the FileReader API:

function registerFilePicker(element, callback) { ... }
Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* ./src/app.js */

function registerFilePicker(element, callback) {
  function handler(event) {
    if (event.target.files.length == 0) {
      event.target.value = null;
      return;
    }
    var pdfFile = event.target.files[0];
    if (pdfFile.type !== "application/pdf") {
      alert("Invalid file type, please load a PDF.");
      return;
    }

    var reader = new FileReader();
    reader.addEventListener("load", function(event) {
      var pdf = event.target.result;
      callback(pdf, pdfFile);
    });
    reader.addEventListener("error", function(error) {
      alert(error.message);
    });
    reader.readAsArrayBuffer(pdfFile);
    event.target.value = null;
  }

  element.addEventListener("change", handler);

  return function() {
    element.removeEventListener("change", handler);
  };
}

callback is a function that gets the pdf in ArrayBuffer format so that we can load it directly with PSPDFKit. It also gets the selected File object.

Once we have this helper, we can add the code to initialize PSPDFKit for Web:

./src/app.js
Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var licenseKeyPromise = fetch("./license-key").then(function(response) {
  return response.text();
});
var pspdfkitInstance = null;
var filePicker = document.querySelector('input[type="file"]');

registerFilePicker(filePicker, function(pdf, fileInfo) {
  if (pspdfkitInstance) {
    PSPDFKit.unload(pspdfkitInstance);
  }

  licenseKeyPromise.then(function(licenseKey) {
    PSPDFKit.load({
      pdf: pdf,
      licenseKey: licenseKey,
      container: ".PSPDFKit-container",
      // See https://pspdfkit.com/api/web/PSPDFKit.Configuration.html#enableServiceWorkerSupport
      enableServiceWorkerSupport: true
    }).then(function(instance) {
      pspdfkitInstance = instance;
    });
  });
});

Our advanced PWA example allows us to load PDF files from a remote server, and it uses IndexedDB to cache them locally for offline usage. It also uses the History API to easily load files via URL.

A Note about Progressive Enhancement

By definition, PWAs are progressive, meaning they are inclusive and they rely heavily on progressive enhancement. When building a PWA, it’s good to always keep this in mind and provide a basic experience for every user of the application.

Richer features should be built on top of an always-working barebones implementation. We highly recommend using feature detection to provide progressive enhancement so that applications won’t break in older browsers that don’t support a specific feature.

Adding Caching and Offline Capabilities with Service Workers

One of the most important features of PWAs is the ability to load fast and to work on slow network conditions or even offline. To achieve this, we can use a service worker to cache the application shell and, when available, use the network only to fetch necessary data.

The service worker is a script we register in the application shell. It runs in the background, separate from a webpage. It can intercept and handle network requests, allowing us to cache responses programmatically.

In case you’re not familiar with service workers, we highly recommend you read this excellent introductory blog post.

Now let’s create the ./serviceWorker.js file:

1
2
3
/* ./serviceWorker.js */

console.log("Hello from the Service Worker");

Then we’ll register it in our application shell:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- ./src/index.html -->

<!DOCTYPE html>
  <html>
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
      />
      <title>PSPDFKit PWA</title>

+      <script>
+        if ('serviceWorker' in navigator) {
+          window.addEventListener('load', function () {
+            navigator.serviceWorker.register('./serviceWorker.js')
+          })
+        }
+      </script>

We are doing feature detection to determine whether service workers are supported and to register our simple serviceWorker.js when the feature is available.

We can now start our application and try it out in a web browser.

Let’s add a simple start script to ./package.json:

Copy
1
2
3
4
5
6
7
{
  "scripts": {
    "prestart":
      "mkdir -p dist && cp -R src/* vendor serviceWorker.js dist",
    "start": "serve ./dist -p 3000"
  }
}

From the command line, let’s launch our web server to serve from the current directory:

1
npm start

The application will run on http://localhost:3000. In the Application tab of Chrome Dev Tools, we can see that our service worker has been registered and is active!

Chrome Developers Tools: Application Tab. Shows the registered Service Worker.

For local development, it’s a good idea to check the Update on reload option so that the service worker is updated every time the page reloads. You can clear the service worker storage any time from this panel using the Clear storage view.

A Note about the Service Worker API

The Service Worker API is low level, flexible, and powerful. Because of this, it usually requires some boilerplate code to do common tasks like activate the service worker, intercept requests and cache responses, clear the cache, and precache files.

To simplify those tasks, Google developed Workbox, an open source library that abstracts away all the complexity and makes building PWAs easy.

In this guide, we will show how to use Workbox to precache the app shell and the PSPDFKit for Web assets.

Note that this is just an example implementation and therefore it is not production ready. A PWA that integrates PSPDFKit for Web will surely have unique requirements that will necessitate fine tuning and ad-hoc solutions.

Precaching

To precache our app shell and PSPDFKit for Web assets, we’re going to use workbox-sw, which is the Google Workbox client-side library.

For the sake of simplicity, we’ll to use the CDN-hosted version of it and include the following directly at the top of our serviceWorker.js file:

Copy
1
2
3
4
5
6
7
/* ./serviceWorker.js */

+importScripts(
+  "https://storage.googleapis.com/workbox-cdn/releases/3.4.1/workbox-sw.js"
+);

console.log("Hello from the Service Worker");

Workbox CLI, another module from Workbox, can generate and inject the precache manifest into serviceWorker.js.

To do so, we will need to install workbox-cli:

1
npm install --save-dev workbox-cli

Then we need to create a configuration file that Workbox CLI will use to generate the precache manifest:

1
touch ./workbox-config.js
Copy
1
2
3
4
5
6
7
8
9
10
/* ./workbox-config.js */

module.exports = {
  globDirectory: "./dist",
  globPatterns: ["**/{*.{js,json,css,html,mem,wasm},license-key}"],
  swDest: "./dist/serviceWorker.js",
  swSrc: "./serviceWorker.js",
  // Up to 30MB so that we can pre cache some of the heavier PSPDFKit assets
  maximumFileSizeToCacheInBytes: 3e7
};

Next, we will need to add a placeholder to serviceWorker.js, which Workbox CLI will replace with the generated manifest:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
/* ./serviceWorker.js */

importScripts(
  "https://storage.googleapis.com/workbox-cdn/releases/3.4.1/workbox-sw.js"
);

-console.log("Hello from the Service Worker");

+/*
+ * workbox-cli will automatically generate a manifest
+ * from workbox-config.js and replace the placeholder below with it.
+ */
+workbox.precaching.precacheAndRoute([]);

Finally, let’s adjust our npm start script to inject the precache manifest on startup:

Copy
1
2
3
4
5
6
7
{
  "scripts": {
    "prestart":
+      "mkdir -p dist && cp -R src/* vendor dist && workbox injectManifest",
    "start": "serve ./dist -p 3000"
  }
}

Note that we are no longer copying serviceWorker.js since Workbox CLI will do that for us.

We can now restart our PWA and open it in a web browser:

1
npm start

When in development mode, Workbox will inform us that some assets have been precached:

Workbox shows the precached files in the Dev Tools console.

If we now kill our web server with ctrl + c and reload the page, we can see that the service worker is serving our assets from the local cache.

Workbox lists all the precached files that are being served.

If you’re making changes to the PWA and Update on reload is disabled, it is important to close the browser tab and reopen the application. Otherwise, the old service worker continues to control the page.

Beyond Precaching

Workbox can help with doing more than precaching, and it allows you to configure how each resource should be cached and how routes should be handled.

Complex applications will likely need to use the network to fetch data before they can render content in the app shell. In those cases, it is very important to choose the right caching strategy for your data.

Please refer to the Workbox website to learn more about how to handle advanced use cases.

Final Touches

Now that our app has offline capabilities, we only need to add a web app manifest to make the application recognizable by the web browser and to describe how the app should behave when installed on users’ devices.

The web app manifest is a file whose name is manifest.json. It contains metadata like the name of the app, the paths to icons and their sizes, the start URL, and the theme color. Let’s create a basic one for our PWA:

1
touch ./src/manifest.json
Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  "name": "PSPDFKit for Web PWA",
  "short_name": "PSPDFKit",
  "icons": [
    {
      "src": "images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#0089AA",
  "theme_color": "#0089AA"
}

Finally, we need to register the manifest in the app pages — in our case, index.html:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
<!-- ./src/index.html -->

<title>PSPDFKit PWA</title>
<script>
  if ("serviceWorker" in navigator) {
    window.addEventListener('load', function () {
      navigator.serviceWorker.register('./serviceWorker.js')
    })
  }
</script>

+<link rel="manifest" href="./manifest.json">

You can verify the manifest in the Application tab of Chrome Dev Tools:

Manifest file in the dev tools.

The web app manifest also makes it possible to display an App Install Banner or an Add to Home Screen dialog. You can follow the guidelines from Google to learn how to create and display one.

Limitations

Disk Quota

Web browsers define quotas either per origin (Chrome and Opera) or per API (IndexedDB, service workers, etc.). When storing files via web APIs, it is a good idea to keep this in mind and ideally monitor the disk quota status to avoid failures. Apps can check how much quota they are using with the Quota Management API. We highly recommend you check out this research report on browser storage from HTML5 Rocks.

Precached PSPDFKit Assets

In this guide, we saw how to precache all of the PSPDFKit for Web assets, including the JavaScript fallback pspdfkit.asm.js. However, when the target browser supports WebAssembly, it would be better to exclude this file from the precache manifest and vice versa: When the target browser doesn’t support WebAssembly, we shouldn’t precache the WASM module pspdfkit.wasm. For production applications, we recommend generating separate manifests and service workers and serving them conditionally.

Conclusion

Hopefully the above information has demonstrated how easy it is to integrate PSPDFKit for Web and make a simple PWA to display PDFs documents. This is possible thanks to Workbox, which provides a simple yet powerful abstraction on top of the Service Worker API.

The PWA we built meets all the requirements of a Baseline PWA, and it is just a proof of concept that should not be used in production.

We also built a more comprehensive and advanced example where PDF documents can be fetched from a remote server and are stored locally in IndexedDB for offline usage. The advanced example also uses the History API to provide a unique working URL for every PDF document, along with a connectivity status indicator.

If you want to learn more about Progressive Web Apps, we highly encourage you to check out the following resources: