Sign in
Log inSign up
Benefits of Progressive Web Applications (PWAs) and How to Build One

Benefits of Progressive Web Applications (PWAs) and How to Build One

Ankita Masand's photo
Ankita Masand
·Jan 11, 2019

In this tutorial, we're going to build up the fundamentals on Progressive Web Applications (PWAs). I'll help you understand the pain points of the traditional web and the need for something better to combat native applications. We'll dive deeper into the components that make up for a PWA - Service Workers, IndexedDB, manifest.json & Web Push Notifications. And the most interesting bit - we'll build a PWA from scratch.

How I Got the Idea to Write This Tutorial

I was having a dinner with my entire family, and a notification for a new text message popped up on my mobile phone. The message confirmed that I received a new paycheck. While this is regular news, my family gets delighted every time this happens.

Taking advantage of the pleasant environment, my brother exclaimed that he wants a new mobile phone. When I asked him why did he need one, he said that his phone has become very slow and gets low memory warnings every now and then. I was surprised to hear this as his phone is more advanced than mine which is still working perfectly fine.

To satiate my curious nerves, I checked his phone and found that he has installed more than 40 applications for his diverse needs. 🤦‍♀️ There were two applications for reading blogs on different categories, two of them were for getting news updates, three of them were E-Commerce apps, three for gaming, one for keeping an eye on his mutual funds and another one for handling his bank account transactions and there were a few more that he didn't use frequently. I asked him if he has ever tried going to the respective website before taking a bold step of installing the native application. He kept aside his piece of pizza and turned towards me in a mood of having a detailed conversation.

He began by saying that he has always visited a website first and its the website that forces him to download t native application by showing fat install banners. He says the experience on the web is so frustrating that it is impossible to get through even a simpler task. His E-Commerce applications are really great at giving him timely updates on his orders and do an amazing job of informing him of discounts by sending push-notifications. The user experience on native applications is simply amazing and web can't beat that. He was stern on his opinion about the web. However, he agreed that the size of native application bloats his phone memory but he cannot do anything in that regard.

Misconceptions People Have About the Web

My brother thinks exactly what most of the users think about the web. The traditional web is slow and ugly. Let's take a moment and check out Twitter on the mobile web, popularly called as Twitter Lite and understand the significance of the word traditional in my last statement.

Twitter lite gif

Is the experience on par with the native app? It loads instantly. There is no janky scrolling. It doesn't look like an old traditional website. You might've noticed a small banner at the bottom asking you to Add Twitter to your home screen. Is it the fancier way of urging users to install the native applications? No, it isn't. It won't download a native application of megabytes in size. It is asking you to add Twitter Lite on your home screen. It literally means adding a shortcut to access twitter mobile web using that icon on the home screen.

Let's experiment this by clicking on Add Twitter to home screen and check out what newer web has to offer. In case the banner didn't appear in your case, please click on the three dots on the right side and choose option Add to home screen. Now, click on the twitter icon from your home screen. Isn't this amazing? Oh yes, this app can also send you real-time push notifications. The web won't feel like a lost world now. Once you opt-in for push notifications on a web application, it does a great job in engaging users by apprising them of all the updates.

There is one more important thing that is lacking in traditional old web - the ability to deal with intermittent or no internet connection. Web behaves quite differently on 2G devices compared to that on WIFI. Most of the times, its nothing or a loader on the screen while browsing on a 2G connection. This is frustrating to the end user. And the good news is, the modern web can deal with this problem too. You don't see the dinosaur when your internet goes off. It's a nice application shell that pops up when you are not connected to the internet. I really like the way, Trivago deals with this problem, they show a nice application shell to play around an offline maze.

Trivago website

Let's check out one more application of this kind - Financial Times. Load Financial Times in your browser and now switch off your internet. Reload the page. The experience is still the same. Isn't this something that makes the web look great? These web applications that solve the pain points of the traditional web are popularly called as Progressive Web Applications.

In this tutorial, we're going to explore Progressive Web Applications and also build one from scratch.

Benefits of Progressive Web Applications

Progressive Web Applications (PWAs) are:

Fast

They make good use of local caches to store static assets. Caching of static assets reduces the number of rides to the server to fetch these assets on every load. This makes for an incredible user experience similar to that of native applications. They respond quickly to user interactions.

Reliable

PWAs load data almost instantly. Every fetch network request from the application goes through Service Workers (More on that later). They operate the cache (IndexedDB or any other local cache). Service Workers can send the response to a network request directly from the cache in case of intermittent or slow internet connections. PWAs work reliably even on 2G connections.

Engaging

Native applications leverage the power of Operating systems to show important notifications to the users and this is one of the powerful features of an application. Sending timely push notifications helps to retain users for a longer duration. PWAs make use of Web Push notifications to inform users of relevant updates.

Progressive Web Applications is used as terminology for web applications that are fast, reliable and engaging and they provide an experience similar to that of native applications. Applications that are eligible to be called as Progressive Web Applications consists of the following -

Service Workers

Service Workers, in simple terms, is a few lines of JavaScript code that keeps running in the background. However, it goes to a dormant state when it is not in use. They operate as an event-driven system. Whenever a particular event (For example, a fetch request to the server) is invoked, service workers come to life. We can handle the response of the fetch event using the fetch event listener in the Service Worker. For a service worker to start doing its work of handling fetch requests and a few other events, it should be registered, installed and activated on a web application.

IndexedDB or Any other Local Cache

PWAs store the static assets like JavaScript files, stylesheets, images in the local cache for subsequent visits. Some of the PWAs make use of IndexedDB, which is basically a structured key-value pair data structure. IndexedDB is used for storing large amounts of data as compared to other client-side storage options. As we saw earlier, the way Financial Times handles the no internet condition. It still displays all the articles on the home page. It makes use of IndexedDB to store data of these articles. Let's see this in action. You'll find IndexedDB in the Chrome DevTools under Applications tab. Under IndexedDB, go to the Articles section.

Web Push Notifications

Service Workers also listen to a push event and has a respective push event handler that takes care of showing the push notification to the user. An application must have user's permission in order to show them push notifications. Once a user opts-in for receiving push notifications, the browser generates a unique token for them. The server can then communicate with the user using this unique token.

manifest.json File

manifest.json is typically a metadata file of an application. An application includes the manifest.json in index.html as follows

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

manifest.json does the job of telling the browser that the application is a PWA. It tells the browser, the Name, Background Color, Theme Color, Icons to be used for this application. It also tells the mode in which the application should be opened. For example, a standalone mode launches a PWA by giving a feel similar to that of a native application.

Rich User Experience

PWAs are known to have a rich user experience. They access the static assets directly from the cache so there is no delay in responding to user interactions.

Let's build a Progressive Web Application by making use of the components listed above -

Use Case - Building a Treasure Trove of Books

We will be building an application called as BooksKeep. It will help in maintaining a systematic record of the books read and also the ones that are in pipeline. No wise word learned should go in vain.

Following features will be incorporated in this application -

  1. Displaying a list of Books (Title, Author, Summary & Favorite Quotes)
  2. Adding a new book to the list

Prerequisites - Our Technology Stack

React - for building the front-end IndexedDB - for storing the books records (Please note, there is no back-end database) WebPack - as a development server and for bundling assets

Let get going! To make things simple, I've created a boilerplate to get started with.

Understanding the Boilerplate

package.json - package.json contains the project dependencies. When you do npm install, these dependencies will be downloaded in your system. Since we're using React to build up our front-end, react & react-dom libraries are included in the dependencies section. In devDependencies object, babel presets and few webpack related plugins are included. Babel is a JavaScript compiler that is used for syntax transformation, converting the next-gen JavaScript into a browser compatible version. Browser doesn't understand the React syntax directly, we're using babel-preset-react to convert React and JSX into JavaScript that the browser understands. We're using WebPack as a module bundler.

webpack.config.js - It contains the configuration setup required for generating a bundle of static assets. The entry object in module.exports contains the entry point of the application, which in our case is app.js. Webpack generates a dependency graph using this entry point and keeps adding dependencies in the bundle starting from app.js. The output object contains the path of the output folder and filename generates dynamic filenames based on their value in the entry object. In our case, it will be bundle.js as we've mentioned bundle in the entry point. Next, there are some rules to convert .js & .scss specific files. These files are to be transformed with their respective loaders before adding them to the main bundle. HTMLWebpackPlugin adds the generated output bundles in the provided index.html template. ExtractTextPlugin moves the .css modules into a separate file. CopyWebpackPlugin simply copies the manifest.json file and service-worker.js file from src to dist.

src - We will be building it up along this tutorial. For now, it contains index.html that has one div element with id as app. app.js is the root component of the application. It contains simple header & body components for now.

Let's start building our BooksKeep PWA. We will be building this progressively in the following steps:

  1. Building a table component for displaying book records
  2. Provision to add a new book in the table
  3. Storing book records in IndexedDB
  4. Adding Service Worker to cache static assets
  5. Adding manifest.json

Building a Table Component for Displaying Book Records

We're using react-bootstrap for building the UI. Let's import Table component from react-bootstrap.

import React, { Component } from 'react'
import { Table } from 'react-bootstrap'
import { BooksHeaders } from '../constants/books-headers'

class Body extends Component {

    constructor (props) {
        super(props)
        this.state = {
            booksData: []
        }
    }

    getTableMarkup = () => {
        return (
            <Table striped bordered condensed hover>
                <thead>
                    <tr>{this.getTableHeaders()}</tr>
                </thead>
                <tbody>
                    {this.getTableData()}
                </tbody>
            </Table>
        )
    }

    getTableHeaders = () => {
        return BooksHeaders.map ((header, index) => {
            return <th width={header.width} key={header.apiKey}>{header.displayName}</th>
        })
    }

    getTableData = () => {
        let { booksData } = this.state
        return booksData.map ((book, index) => {
            return (
                <tr key={book.name}>
                    {
                        BooksHeaders.map ((header, subIndex) => {
                            let value = book[header.apiKey]
                            if (header.apiKey === '#') {
                                value = index+1
                            }
                            return <td width={header.width} key={header.apiKey}>{value}</td>
                        })
                    }
                </tr>
            )
        })
    }

    render () {
        return (
            <div>
                <h4>Your Books &hearts;</h4>
                {this.getTableMarkup()}
            </div>
        )
    }
}

export default Body

Start the server by using the command npm start in your terminal. Head over to localhost:8080/dist/. We don't have any book record yet so the table is empty. BooksHeaders are being imported from the constants folder. Please add BooksHeaders in books-headers.js file in the constants folder from here. BooksHeaders is simply an array of objects that we're displaying in the table. getTableMarkup function builds up the table headers with getTableHeaders & body with getTableData functions. booksData maintains the state of the component. If any new book record is to be added, it should be pushed into booksData array.

Provision to Add a New Book in the Table

Let's take it further and add our first book record in the table. All we have to add is import BookForm component from the base folder and provide it a onSubmit prop. onSubmit prop accepts a function that will be called when user clicks on the submit button in the form and that will give us the details of the new book. Once you're done with that, your Body Component should look like this

Here's BookForm component -

import React, { Component } from 'react'
import { FieldGroup } from '../../utils/field-group'
import { Button } from 'react-bootstrap'

class BookForm extends Component {

    constructor (props) {
        super(props)
        this.state = {
            formData: props.formData || {}
        }
    }

    handleChange = (e, type) => {
        let { formData } = this.state
        let data = Object.assign({}, formData)
        data[type] = e.target.value
        this.setState({ formData: data })
    }

    submitForm = (e) => {
        e.preventDefault()
        let { formData } = this.state
        let { onSubmit } = this.props
        onSubmit(formData)
        this.clearForm()
    }

    clearForm = () => {
        this.setState({ formData: {} })
    }

    render () {
        let { onClose } = this.props
        let { formData } = this.state
        return (
            <form>
                <FieldGroup
                    id='name'
                    type='text'
                    label='Name'
                    value={formData['name'] || ''}
                    onChange={ (e) => this.handleChange(e, 'name') }
                />
                <FieldGroup
                    id='author'
                    type='text'
                    label='Author'
                    value={formData['author'] || ''}
                    onChange={ (e) => this.handleChange(e, 'author') }
                />
                <FieldGroup
                    id='summary'
                    type='textarea'
                    label='Summary'
                    value={formData['summary'] || ''}
                    onChange={ (e) => this.handleChange(e, 'summary') }
                />
                <Button type='submit' bsStyle='primary' onClick={this.submitForm}>Submit</Button>
                {
                    onClose ? <Button onClick={onClose}>Close</Button> : null
                }
            </form>
        )
    }
}

export default BookForm

FieldGroup is just a wrapper for labeled inputs. Please put this inside field-group.js file in the utils folder. BookForm component maintains its state in the formData object. Whenever a user enters name, author or summary, it gets saved in the component state. Submit button passes the component state to the parent Body component, which then adds it to its state - booksData array. After adding a book record, you'll see that your table is now populated with that record. But when you refresh the page, all of this is gone. We've to fix this.

Storing Book Records in IndexedDB

IndexedDB is a structured client-side storage database. The records in IndexedDB are stored as key-value pairs. We'll be saving the book records in IndexedDB. IndexedDB provides APIs for adding, deleting and updating the records in a database. Let's explore these APIs by creating a wrapper in the indexeddb.js file in utils folder.

Operations performed on IndexedDB are asynchronous in nature. So, the IndexedDB APIs provide appropriate hooks for success and error events.

First, we'll have to create our database. Let's write a initialize function that will handle the initialization tasks

const DB = 'BooksKeep'
const OBJECTSTORE = 'books'
let idb = null
let dbInstance = null
const initialize = (callback) => {
    idb = window.indexedDB
    let request = idb.open(DB, 1)

    request.onsuccess = (event) => {
        dbInstance = event.target.result
        callback()
    }

    request.onerror = (event) => {
        console.log('Error in creating BooksKeep database', request.error)
    }

    request.onupgradeneeded = (event) => {
        if (!dbInstance) {
            dbInstance = event.target.result
        }

        dbInstance.createObjectStore(OBJECTSTORE, { keyPath: 'id' })
    }
}

In the above code snippet, BooksKeep is the name of the IndexedDB database and books is an ObjectStore. ObjectStore is analogous to a table in SQL. The statement idb.open(DB, 1) is an asynchronous request to open IndexedDB database BooksKeep and the second parameter 1 signifies the version of the database. The request variable is of type IDBOpenDBRequest. We've defined onsuccess, onerror and onupgradeneeded functions on the request object to be called at the respective events. For example, onsuccess callback would be called when the database is opened successfully and in onsuccess method, we're caching the instance of the BooksKeep database. onupgradeneeded method is invoked whenever there is a change in the version of the database. Currently with version 1, we've added only one ObjectStore called as books. Let's say, at a later stage, when our application grows we decide to add one more ObjectStore. We'll have to upgrade the version of our database to 2 and add the schema of this new ObjectStore in onupgradeneeded method.

We will be writing three important methods - get, update & delete in our IndexedDB wrapper. The general idea for performing any of these operations is to first get the instance of the store, wrap the operation in a transaction (A transaction is simply a wrapper around an operation to ensure data integrity. If any of the actions in a transaction fails, then no action is performed on the database.) and then write success and error event handlers for the respective asynchronous requests. For example, our put or update method will look something like this -

const update = (type, data, callback) => {
    let transaction = dbInstance.transaction(type, 'readwrite')
    let store = transaction.objectStore(type)
    if (!data.id) {
        data.id = new Date().getTime()
    }
    let updateRequest = store.put(data)
    updateRequest.onsuccess = (event) => {
        typeof callback === 'function' && callback(data)
    }
}

update method takes three parameters - type is the name of the objectStore, data is the book record that we intend to add/update in our objectStore and callback is of type function that would be called after successfully adding data in the objectStore. transaction is defined on the IDBOpenDBRequest instance and it takes the name of the objectStore and the mode with which the operation is to be performed. In this case, the mode is readwrite since we're writing to the objectStore. As mentioned previously, IndexedDB accepts data in the form of key-value pairs. We're using timestamp to generate a unique identifier for a particular record. store.put(data) asynchronously adds book record into the books objectStore. On the same lines, I've added get & delete methods in our wrapper. Please check the complete code of IndexedDB wrapper from here.

Now that our IndexedDB wrapper is all set, it's time to use the add/update function from our wrapper whenever a user tries to add a new book record. Let's modify our Body Component to accommodate these changes. First import IndexedDbWrapper in the Body component. We will be calling the initialize function of IndexedDbWrapper in componentDidMount. The initialize method takes the callback as initializeDB function, which is defined in the Body Component. initializeDB does the work of setting up the initial state of our application by fetching the stored books records from IndexedDB. One last thing to do with IndexedDbWrapper is to call its update method on submission of a book record. We've to modify the onSubmit method of the Body component as -

onSubmit = (formData) => {
    let { booksData} = this.state
    let newBooksData = [ ...booksData ]
    IndexedDbWrapper.update('books', formData, (data) => {
        newBooksData.push(formData)
        this.setState({ booksData: newBooksData })
    })
}

Now, the new record will be first added to IndexedDB and once that is done successfully, we're updating the state of the component. Try adding a new book record and reload the page. You will still see your book record in the table. Here's where it is coming from!

Let's add one record and refresh the page. Data is preserved and that's exactly what we wanted. We've built up a means to fetch data directly on the client-side. We're getting closer to our goal of building a Progressive Web Application.

Adding Service Worker to Cache Static Assets

The next step is to leverage the power of Service Workers by fetching static assets from the cache. A service worker first has to be registered on a web page.

Service Worker Registration

initializeSW = () => {
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('./service-worker.js')
            .then ( () => {
                console.log('Service Worker registered successfully!')
            })
    } else {
        console.log('Service Worker is not supported in your browser')
    }
}

initializeSW function is defined in the Body component and we'll call it in componentDidMount lifecycle hook. serviceWorker is defined on navigator (The Navigator interface represents the state and the identity of the user agent. It allows scripts to query it and to register themselves to carry on some activities. - MDN). A Service Worker is registered using the register method defined on navigator.serviceWorker object. The register method takes the URL of the service worker file. It returns a Promise which resolves when the service worker is registered successfully on the webpage. Once this is done, you'll see a success message in the console. By default, service worker can intercept all the fetch requests coming from the web page.

register method also takes an optional second parameter, which defines the scope of the service worker.

navigator.serviceWorker.register('./service-worker.js', { scope: '/products' })

The above service worker will intercept only /products/* requests. So, something like /payments is not intercepted by the above service worker.

As said earlier, Service Worker operates as an event-driven system. After successful registration, an install event is triggered. We can make use of install event handler for initialization tasks. In our case, we willw be setting up our cache for storing static assets.

Here's the install event handler

self.addEventListener('install', (event) => {
    console.log('Installing service worker...')
    const CACHE_NAME = 'bookskeep-cache'
    const urlsToCache = [
        '/dist/',
        '/dist/css/main.css',
        '/dist/js/bundle.js'
    ]
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then (cache => {
                cache.addAll(urlsToCache)
            })
    )
})

event.waitUntil makes sure that the service worker is active while the URLS are getting added into the cache.

Service worker doesn't yet start doing its magic. After getting installed successfully, an activate event is triggered and this is a good place to clear off old unused caches. Let's do our bit -

self.addEventListener('activate', (event) => {
    console.log('Activating service worker...')
    const cacheWhitelist = ['bookskeep-cache']
    event.waitUntil(
        caches.keys()
            .then (cacheNames => {
                return Promise.all(
                    cacheNames.map (cacheName => {
                        if (cacheWhitelist.indexOf(cacheName) === -1) {
                            return caches.delete(cacheName)
                        }
                    })
                )
            })
    )
})

activate event handler takes care of deleting all the caches except bookskeep-cache. When a web page makes a network request to the server, the fetch event of service worker is triggered. So, if we were to manipulate or modify the response to be sent for a particular request, we'll have to do this in the fetch event handler.

self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.open('bookskeep-cache')
            .then (cache => {
                return cache.match(event.request).then (response => {
                    if (response) {
                        console.log('Cache hit! Fetching response from cache', event.request.url)
                        return response
                    }
                    return fetch(event.request).then (response => {
                        cache.put(event.request, response.clone())
                        return response
                    })
                })
            })
    )
})

event.respondWith method lets us send a modified response back to the client. It returns a Promise that resolves to a valid response. cache.match checks if the request is a valid resource for caching (If you remember, we've added a few specific URLs to urlsToCache variable in the install event handler). If the response to that request is present in the cache, we send it directly to the client otherwise, we request that resource from the server, put it into the cache for subsequent hits and send it to the client.

Here's the service worker file with the three event handlers explained above.

Adding manifest.json

{
    "short_name": "BooksKeep",
    "name": "BooksKeep",
    "icons": [
        {
            "src": "/images/icon.jpg",
            "type": "image/jpg",
            "sizes": "192x192"
        }
    ],
    "start_url": "/dist/",
    "display": "standalone",
    "background_color": "#3367D6",
    "theme_color": "#3367D6"
}

short_name is used on the home screen as the name of the application. In case, short_name is not provided then name property is used in its place. icons show up as a home screen icon for the application in the app launcher and on the splash screen. start_url tells the browser about the starting page of the app. A user will be directed to this URL when the app is launched. standalone as the display property of the app gives it the look & feel of a native application. The application runs in its own window and hides some of the browser specific elements like URL bar. background_color sets the color of the splash screen when the application is first launched and theme_color tells about the color of the toolbar.

Summary

This is it. We've set up our BooksKeep PWA. Let's have a quick recap of the things we learned in this tutorial:

  1. The traditional web lacks some of the important features that native applications provide out of the box. Progressive Web Applications helps in improving the user experience on the web tremendously. They are fast, reliable and engaging and provide an experience similar to that of native applications.
  2. PWAs make use of Service Workers, IndexedDB (or any other local cache), manifest.json and Web Push Notifications.
  3. Service Workers operate as an event-driven system and listen for fetch & push events. fetch event lets us send the response to a network request directly from the cache in case of slow or intermittent connections. push event lets us show push-notifications to the user and helps in engaging user by apprising them of timely updates.
  4. IndexedDB is a key-value structure. It helps in storing a massive amount of data on client-side. manifest.json informs the browser about some of the important properties of an application.
  5. We learned how to get started in building a Progressive Web Application.

This was a quick introduction to Progressive Web Applications. If you want to explore more, here are some of the resources -

  1. An Extensive Guide To Progressive Web Applications
  2. Check out my BooksKeep application on GitHub. I've added a few more features like updating book records, adding quotes & support for web push notifications. I'll keep adding more!
  3. Service Workers

Please let me know if you found this tutorial to be helpful and share it with whomever you think might benefit from it.