Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Introduction to Progressive Web Apps (Offline First)

DZone's Guide to

Introduction to Progressive Web Apps (Offline First)

With progressive web apps, developers can deliver app-like experiences to users using modern web technologies, even when they're offline.

· Mobile Zone
Free Resource

Get gorgeous, multi-touch charts for your iOS application with just a few lines of code.

A progressive web application is basically a website built using modern web technologies but acts and feels like a mobile app. In 2015, Alex Russell, Google engineer, and Frances Berriman coined the term progressive web apps. Google has been immensely working on making sure that progressive web apps can really give users that native-app-like experience. The flow of a typical progressive web app goes thus:

  • Starts out as accessible in tabs on the Web browser.
  • Shows the option of adding to the home screen of the device.
  • Progressively starts exhibiting app-like properties such as offline usage, push notifications, and background sync.

Until now, mobile apps could do a lot of things that web apps couldn't really do. Progressive web apps, are web apps that try to do what mobile apps have been doing for a long time. They are web applications that combine the best of the web and the best of apps. Progressive web apps, can load very fast on slow network connections, work offline, send push notifications, and load on the home screen with the power of Web App Manifest.

Remember the splash screen that native apps provide? Right now, the latest versions of Chrome on Android support the use of a splash screen to give your web app a native experience, all thanks to progressive web apps.

Splash Screen

Source: developers.google.com

Features of Progressive Web Apps

What does it mean for a web app to be progressive? This new class of web applications have characteristics that define their existence. Without much ado, these are the features of progressive web apps:

  • Responsive: The UI must fit the devices form factor: desktop, mobile, and tablet.
  • App-like: When interacting with a progressive web app, it should feel like a native app.
  • Connectivity Independent: It should work offline (via Service Workers) or in areas of low connectivity.
  • Re-engageable: Through features like push notifications, users should be able to consistently engage and re-use the app.
  • Installable: A user should be able to add it on their home screen and just launch it from there whenever they need to re-use the app.
  • Discoverable: Should be identified as applications and be discoverable by search engines.
  • Fresh: Should be able to serve new content in the app when the user is connected to the internet.
  • Safe: Should be served via HTTPS to prevent content-tampering and man-in-the-middle attacks.
  • Progressive: Regardless of the browser choice, it should work for every user.
  • Linkable: Easy to share via URL.

Production Use Cases of Progressive Web Apps

Several developers and companies have re-developed their websites into progressive web apps. I'll give a summary of three significant products that are progressive web apps and the benefits they have accrued over time.

  • Flipkart Lite: FlipKart is one of India's largest online shops. They created a progressive web app, Flipkart Lite that resulted in a 70% increase in conversions. They took advantage of the super-powers progressive web apps offer by using service workers, push notifications, add to home screen, splash screen, and smooth animations and it resulted in the following, according to the Google PWA Showcase:
    • 3x less data usage.
    • 40% higher re-engagement rate.
    • Users spend more time on the platform.
    • 70% conversion rate.

Flipkart SplashscreenFlipkart SplashscreenFlipkart Homescreen optionAdd to home screen on Flipkart

You can find more information on the case study here.

  • Housing: Housing.com is one of India's foremost startups. They provide an online real estate platform in India. They created a progressive web app which resulted in a 38% increase in conversions across browsers and also the following:
    • 40% lower bounce rate.
    • 10% longer average session.
    • 30% faster page load.

Housing HomescreenAdd to home screen on Housing GO

Housing - Push Notifications OptionOption to turn on push notifications

You can find more information on the case study here.

  • AliExpress: AliExpress, the very popular global online retail marketplace had the challenge of getting users to download their mobile app and re-engage as much as they wanted. To solve this challenge, they decided to create a progressive web app for their mobile web users, and the results were very impressive:
    • 104% increase in conversion rate for new users.
    • 74% increase in time spent per session across all browsers.
    • 2X more pages visited per session per user across all browsers.

AliExpress Mobile

AliExpress mobile navigation

AliExpress Homepage

AliExpress mobile homepage

You can find more information on the case study here.

These companies have benefitted immensely from deploying progressive web apps. Next, let's dive in further into one of the major components that makes up what we call a progressive web app, service workers.

Service Workers

A service worker is a programmable proxy, a script, that your browser runs in the background. It has the ability to intercept and handle HTTP requests and also respond to them in various ways. It responds to network requests, push notifications, connectivity changes and many more. Jeff Posnick, a Google engineer, gave one of the best explanations that I have seen:

Service Worker is an air traffic controller. Think of your web apps requests as planes taking off. Service Worker is the air traffic controller that routes the requests. It can load from the network or even off the cache.

A service worker can't access the DOM, but it can make use of the fetch and Cache APIs. You can use the service worker to cache all static resources, which automatically reduces network requests and improve performance. The Service worker can be used to display the application shell, inform users that they are disconnected from the internet and serve up a page for the user to interact with once they are offline.

A service worker file, eg sw.js needs to be placed in the root directory like so:

Service Worker JavaScript file

Service worker file in the root directory

To get started with service workers in your progressive web app, you need to register the service worker in your app's js file. If your application's js file was app.js, then inside the file, we'll have a piece of JavaScript code like so:

  if ('serviceWorker' in navigator) {
    navigator.serviceWorker
             .register('./sw.js')
             .then(function() { console.log('Service Worker Registered'); });
  }


The piece of code above checks if the browser supports service workers, and if it does, registers the service worker file. Once the service worker is registered, we start to experience it's lifecycle the moment a user visits the page for the first time.

The service worker's lifecycle goes thus:

  • Install: An install event is triggered the first time a user visits the page. During this phase, the service worker is installed in the browser. During this installation, you can cache all the static assets in your web app like so:
// Install Service Worker
self.addEventListener('install', function(event) {

    console.log('Service Worker: Installing....');

    event.waitUntil(

        // Open the Cache
        caches.open(cacheName).then(function(cache) {
            console.log('Service Worker: Caching App Shell at the moment......');

            // Add Files to the Cache
            return cache.addAll(filesToCache);
        })
    );
});
  • The filesToCache variable represents an array of all the files you want to cache.
  • The cacheName refers to the name given to the cache store.

  • Activate: This event is fired when the service worker starts up.

// Fired when the Service Worker starts up
self.addEventListener('activate', function(event) {

    console.log('Service Worker: Activating....');

    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(cacheNames.map(function(key) {
                if( key !== cacheName) {
                    console.log('Service Worker: Removing Old Cache', key);
                    return caches.delete(key);
                }
            }));
        })
    );
    return self.clients.claim();
});

Here, the service worker updates its cache whenever any of the app shell files change.

  • Fetch: This event helps serve the app shell from the cache. caches.match() dissects the web request that triggered the event, and checks to see if it's available in the cache. It then either responds with the cached version, or uses fetch to get a copy from the network. The response is returned to the web page with e.respondWith().
self.addEventListener('fetch', function(event) {

    console.log('Service Worker: Fetch', event.request.url);

    console.log("Url", event.request.url);

    event.respondWith(
        caches.match(event.request).then(function(response) {
            return response || fetch(event.request);
        })
    );
});


At this time of writing, service workers are supported by Chrome, Opera, and Firefox. Safari and Edge don't support it yet.

Service Worker SupportService worker

The Service Worker Specification and primer are very useful resources for learning more about Service Workers.

Application Shell

Earlier in the post, I made mention of the app shell at various times. The application shell is the minimal HTML, CSS and JavaScript powering the user interface of your app. A progressive web app ensures that the application shell is cached to ensure fast and instant loading on repeated visits to the app.

Application ShellApplication shell

What We Will Be Building

We'll build a simple progressive web app. The app simply tracks the latest commits from a particular open source project. As a progressive web app, it should:

  • Work offline. A user should be able to view the latest commits without an internet connection.
  • The app should load instantly on repeated visits.
  • Once the push notification button is turned on, the user should get a notification on the latest commits to the open source project.
  • Be installable( added to the home screen).
  • Have a web application manifest.

Talk Is Cheap, Let's Build

Create an index.html and latest.html file in your code directory like so:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Commits PWA</title>
  <link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>
    <div class="app app__layout">
      <header>
        <span class="header__icon">
          <svg class="menu__icon no--select" width="24px" height="24px" viewBox="0 0 48 48" fill="#fff">
            <path d="M6 36h36v-4H6v4zm0-10h36v-4H6v4zm0-14v4h36v-4H6z"></path>
          </svg>
        </span>

        <span class="header__title no--select">PWA - Home</span>
      </header>

      <div class="menu">
        <div class="menu__header"></div>
        <ul class="menu__list">
          <li><a href="index.html">Home</a></li>
          <li><a href="latest.html">Latest</a></li>
      </div>

      <div class="menu__overlay"></div>

      <div class="app__content">

        <section class="section">
          <h3> Stay Up to Date with R-I-L </h3>
          <img class="profile-pic" src="./images/books.png" alt="Hello, World!">

          <p class="home-note">Latest Commits on Resources I like!</a></p>
        </section>


        <div class="fab fab__push">
          <div class="fab__ripple"></div>
          <img class="fab__image" src="./images/push-off.png" alt="Push Notification" />
        </div>

        <!-- Toast msg's -->
        <div class="toast__container"></div>
      </div>
    </div>

    <script src="./js/app.js"></script>
    <script src="./js/toast.js"></script>
    <script src="./js/offline.js"></script>
    <script src="./js/menu.js"></script>
</body>
</html>

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Commits PWA</title>
  <link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>
    <div class="app app__layout">
      <header>
        <span class="header__icon">
          <svg class="menu__icon no--select" width="24px" height="24px" viewBox="0 0 48 48" fill="#fff">
            <path d="M6 36h36v-4H6v4zm0-10h36v-4H6v4zm0-14v4h36v-4H6z"></path>
          </svg>
        </span>
        <span class="header__title no--select">PWA - Commits</span>
      </header>

      <div class="menu">
        <div class="menu__header"></div>
        <ul class="menu__list">
          <li><a href="index.html">Home</a></li>
          <li><a href="latest.html">Latest</a></li>
        </ul>
      </div>

      <div class="menu__overlay"></div>

      <section class="card_container">
        <h2 style="margin-top:70px;" align="center">Latest Commits!</h2>



        <div class="container">
            <section class="card first">

            </section>
            <section class="card second">

            </section>
            <section class="card third">

            </section>
            <section class="card fourth">

            </section>
            <section class="card fifth">

            </section>
        </div>
      </section>

       <div class="loader">
          <svg viewBox="0 0 32 32" width="32" height="32">
            <circle id="spinner" cx="16" cy="16" r="14" fill="none"></circle>
          </svg>
        </div>

      <!-- Toast msg's -->
      <div class="toast__container"></div>      
    </div>



  <script src="./js/app.js"></script>
  <script src="./js/latest.js"></script>
  <script src="./js/toast.js"></script>
  <script src="./js/offline.js"></script>
  <script src="./js/menu.js"></script>
</body>
</html>


Create a css folder in your directory and grab the style.css file from here.

Create a js folder in your directory and add the following files: app.js, menu.js, offline.js, latest.js, toast.js

(function () {
 'use strict';

  var header = document.querySelector('header');
  var menuHeader = document.querySelector('.menu__header');

  //After DOM Loaded
  document.addEventListener('DOMContentLoaded', function(event) {
    //On initial load to check connectivity
    if (!navigator.onLine) {
      updateNetworkStatus();
    }

    window.addEventListener('online', updateNetworkStatus, false);
    window.addEventListener('offline', updateNetworkStatus, false);
  });

  //To update network status
  function updateNetworkStatus() {
    if (navigator.onLine) {
      header.classList.remove('app__offline');
      menuHeader.style.background = '#1E88E5'; 
    }
    else {
      toast('You are now offline..');
      header.classList.add('app__offline');
      menuHeader.style.background = '#9E9E9E';
    }
  }
})();

offline.js

The code above helps the user visually differentiate offline from online.

(function () {
 'use strict';

  var menuIconElement = document.querySelector('.header__icon');
  var menuElement = document.querySelector('.menu');
  var menuOverlayElement = document.querySelector('.menu__overlay');

  //Menu click event
  menuIconElement.addEventListener('click', showMenu, false);
  menuOverlayElement.addEventListener('click', hideMenu, false);
  menuElement.addEventListener('transitionend', onTransitionEnd, false);

   //To show menu
  function showMenu() {
    menuElement.style.transform = "translateX(0)";
    menuElement.classList.add('menu--show');
    menuOverlayElement.classList.add('menu__overlay--show');
  }

  //To hide menu
  function hideMenu() {
    menuElement.style.transform = "translateX(-110%)";
    menuElement.classList.remove('menu--show');
    menuOverlayElement.classList.remove('menu__overlay--show');
    menuElement.addEventListener('transitionend', onTransitionEnd, false);
  }

  var touchStartPoint, touchMovePoint;

  /*Swipe from edge to open menu*/

  //`TouchStart` event to find where user start the touch
  document.body.addEventListener('touchstart', function(event) {
    touchStartPoint = event.changedTouches[0].pageX;
    touchMovePoint = touchStartPoint;
  }, false);

  //`TouchMove` event to determine user touch movement
  document.body.addEventListener('touchmove', function(event) {
    touchMovePoint = event.touches[0].pageX;
    if (touchStartPoint < 10 && touchMovePoint > 30) {          
      menuElement.style.transform = "translateX(0)";
    }
  }, false);

  function onTransitionEnd() {
    if (touchStartPoint < 10) {
      menuElement.style.transform = "translateX(0)";
      menuOverlayElement.classList.add('menu__overlay--show');
      menuElement.removeEventListener('transitionend', onTransitionEnd, false); 
    }
  }
})();

menu.js

The code above is responsible for the animation of the menu ellipsis button

(function (exports) {
 'use strict';

  var toastContainer = document.querySelector('.toast__container');

  //To show notification
  function toast(msg, options) {
    if (!msg) return;

    options = options || 3000;

    var toastMsg = document.createElement('div');

    toastMsg.className = 'toast__msg';
    toastMsg.textContent = msg;

    toastContainer.appendChild(toastMsg);

    //Show toast for 3secs and hide it
    setTimeout(function () {
      toastMsg.classList.add('toast__msg--hide');
    }, options);

    //Remove the element after hiding
    toastMsg.addEventListener('transitionend', function (event) {
      event.target.parentNode.removeChild(event.target);
    });
  }

  exports.toast = toast; //Make this method available in global
})(typeof window === 'undefined' ? module.exports : window);


The code above is responsible for app-like toast notification pop-up timed widget.

The latest.js and app.js can be empty for now.

Now, spin up your app using a local server, e.g http-server, your web app should look like this:

Sidemenu

Side menu

Index

Index page

Latest Page

Latest page

Higlighted App shell

Application shell

Your application shell is also highlighted above. No dynamic content loaded yet. Next, we need to fetch commits from Github's API.

Fetch Dynamic Content

Open up your latest.js file and add the code below:

(function() {
 'use strict';

  var app = {
    spinner: document.querySelector('.loader')
  };

  var container = document.querySelector('.container');


  // Get Commit Data from Github API
  function fetchCommits() {
    var url = 'https://api.github.com/repos/unicodeveloper/resources-i-like/commits';

    fetch(url)
    .then(function(fetchResponse){ 
      return fetchResponse.json();
    })
    .then(function(response) {

        var commitData = {
            'first': {
              message: response[0].commit.message,
              author: response[0].commit.author.name,
              time: response[0].commit.author.date,
              link: response[0].html_url
            },
            'second': {
              message: response[1].commit.message,
              author: response[1].commit.author.name,
              time: response[1].commit.author.date,
              link: response[1].html_url
            },
            'third': {
              message: response[2].commit.message,
              author: response[2].commit.author.name,
              time: response[2].commit.author.date,
              link: response[2].html_url
            },
            'fourth': {
              message: response[3].commit.message,
              author: response[3].commit.author.name,
              time: response[3].commit.author.date,
              link: response[3].html_url
            },
            'fifth': {
              message: response[4].commit.message,
              author: response[4].commit.author.name,
              time: response[4].commit.author.date,
              link: response[4].html_url
            }
        };

        container.querySelector('.first').innerHTML = 
        "<h4> Message: " + response[0].commit.message + "</h4>" +
        "<h4> Author: " + response[0].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[0].commit.author.date)).toUTCString() +  "</h4>" +
        "<h4>" + "<a href='" + response[0].html_url + "'>Click me to see more!</a>"  + "</h4>";

        container.querySelector('.second').innerHTML = 
        "<h4> Message: " + response[1].commit.message + "</h4>" +
        "<h4> Author: " + response[1].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[1].commit.author.date)).toUTCString()  +  "</h4>" +
        "<h4>" + "<a href='" + response[1].html_url + "'>Click me to see more!</a>"  + "</h4>";

        container.querySelector('.third').innerHTML = 
        "<h4> Message: " + response[2].commit.message + "</h4>" +
        "<h4> Author: " + response[2].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[2].commit.author.date)).toUTCString()  +  "</h4>" +
        "<h4>" + "<a href='" + response[2].html_url + "'>Click me to see more!</a>"  + "</h4>";

        container.querySelector('.fourth').innerHTML = 
        "<h4> Message: " + response[3].commit.message + "</h4>" +
        "<h4> Author: " + response[3].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[3].commit.author.date)).toUTCString()  +  "</h4>" +
        "<h4>" + "<a href='" + response[3].html_url + "'>Click me to see more!</a>"  + "</h4>";

        container.querySelector('.fifth').innerHTML = 
        "<h4> Message: " + response[4].commit.message + "</h4>" +
        "<h4> Author: " + response[4].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[4].commit.author.date)).toUTCString() +  "</h4>" +
        "<h4>" + "<a href='" + response[4].html_url + "'>Click me to see more!</a>"  + "</h4>";

        app.spinner.setAttribute('hidden', true); //hide spinner
      })
      .catch(function (error) {
        console.error(error);
      });
  };

  fetchCommits();
})();


In addition, reference the latest.js script in your latest.html file like so:

<script src="./js/latest.js"></script>


Also, add the spinner to your latest.html file like so:

....
<div class="loader">
      <svg viewBox="0 0 32 32" width="32" height="32">
        <circle id="spinner" cx="16" cy="16" r="14" fill="none"></circle>
      </svg>
</div>

<div class="toast__container"></div>


In the latest.js code, you can observe that we are fetching the commits from Github's API and appending them to the DOM. Now our latest.html page should look like this:

Latest CommitsLatest.html page

Precache the App Shell With Service Workers

We need to cache our app shell using a service worker to ensure our app loads super-fast and work offline.

  • First, create a service worker file in your root directory. Name it sw.js
  • Second, Open up your app.js file and register the service worker by adding this piece of code like so:
  if ('serviceWorker' in navigator) {
     navigator.serviceWorker
             .register('./sw.js')
             .then(function() { console.log('Service Worker Registered'); });
  }
  • Open the sw.js file and add this piece of code like so:
var cacheName = 'pwa-commits-v3';

var filesToCache = [
    './',
    './css/style.css',
    './images/books.png',
    './images/Home.svg',
    './images/ic_refresh_white_24px.svg',
    './images/profile.png',
    './images/push-off.png',
    './images/push-on.png',
    './js/app.js',
    './js/menu.js',
    './js/offline.js',
    './js/toast.js'
];

// Install Service Worker
self.addEventListener('install', function(event) {

    console.log('Service Worker: Installing....');

    event.waitUntil(

        // Open the Cache
        caches.open(cacheName).then(function(cache) {
            console.log('Service Worker: Caching App Shell at the moment......');

            // Add Files to the Cache
            return cache.addAll(filesToCache);
        })
    );
});


// Fired when the Service Worker starts up
self.addEventListener('activate', function(event) {

    console.log('Service Worker: Activating....');

    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(cacheNames.map(function(key) {
                if( key !== cacheName) {
                    console.log('Service Worker: Removing Old Cache', key);
                    return caches.delete(key);
                }
            }));
        })
    );
    return self.clients.claim();
});


self.addEventListener('fetch', function(event) {

    console.log('Service Worker: Fetch', event.request.url);

    console.log("Url", event.request.url);

    event.respondWith(
        caches.match(event.request).then(function(response) {
            return response || fetch(event.request);
        })
    );
});


Like I explained in the earlier part of this post, all our assets are in the filesToCache array. As the service worker gets installed, it opens the cache in the browser and adds all the files we defined in the array to the pwa-commits-v3 cache. The install event fires once the service worker is already installed. This phase ensures that your service worker updates its cache whenever any of the app shell files change. The fetch event phase serves the app shell from the cache.

Note: Check out Google's sw-toolbox and sw-precache libraries for easier and better way of precaching your assets and generating service workers.

Now reload your web app and open DevTools. Go the Service Worker pane on the Application tab.

Note: Ensure, you enable the Update on reload checkbox to force the service worker to update on every page reload.

Service Worker

Works Offline or Not?

Now, reload your page and then go to the Cache Storage pane on the Application tab of Chrome DevTools. Expand the section and you should see the name of our app shell cache listed on the left-hand side like so:

Cache

Cache storage

When you click on your app shell cache, you can see all of the resources that it has currently cached. Let's test out its offline capability now. Head over to the Service Worker pane again and tick the Offline checkbox. A small yellow warning icon should appear next to the Network tab like so:

Offline-Network Tab

Offline network tab in Chrome DevTools

Now, reload your web page and check it out. Does it work offline?

Index Page Offline

Index page offline

Yaaay!!! The index page is served offline. What about the latest page that shows the latest commits?

Latest Page Offline

Latest page offline

Yaaay!!! The latest page is served offline. But wait a minute! Where is the data? Where are the commits? Oops! Our app still tries to query the GitHub API when the user is disconnected from the internet and it fails.

Behind the Scenes Fetch Offline failure

Data fetch failure, Chrome DevTools

What do we do? There are different ways to handle this scenario. One of the many options is telling the service worker to serve up an offline page. Another option is to cache the commit data on first load, load locally-saved data on subsequent requests, then fetch recent data later when the user is connected. The commit data can be stored in IndexedDB or local Storage.

Well, let's conclude here for now!

Aside: Easy Authentication with Auth0

You can use Auth0 Lock for your progressive web app. With Lock, showing a login screen is as simple as including the auth0-lock library and then calling it in your app like so:


// Initiating our Auth0Lock
var lock = new Auth0Lock(
  'YOUR_CLIENT_ID',
  'YOUR_AUTH0_DOMAIN'
);

// Listening for the authenticated event
lock.on("authenticated", function(authResult) {
  // Use the token in authResult to getProfile() and save it to localStorage
  lock.getProfile(authResult.idToken, function(error, profile) {
    if (error) {
      // Handle error
      return;
    }

    localStorage.setItem('idToken', authResult.idToken);
    localStorage.setItem('profile', JSON.stringify(profile));
  });
});

Implementing Lock


document.getElementById('btn-login').addEventListener('click', function() {
  lock.show();
});

Showing Lock

Auth0 Lock Screen

Auth0 lock screen

In the case of an offline-first app, authenticating the user against a remote database won't be possible when network connectivity is lost. However, with service workers, you have full control over which pages and scripts are loaded when the user is offline. This means you can configure your offline.html file to display a useful message stating the user needs to regain connectivity to login again instead of displaying the Lock login screen.

Conclusion

In this article, we were able to cover the basics of how progressive web apps work in general. We were also able to make our app partially work offline.

In the next part of this tutorial, we will cover how to make our app fully work offline and load instantly by storing the dynamic commit data in the browser using one of its available form of storage.

.Net developers: use Highcharts, the industry's leading interactive charting library, without writing a single line of JavaScript.

Topics:
progressive web apps ,mobile ,offline web applications ,tutorial ,javascript

Published at DZone with permission of Prosper Otemuyiwa, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

THE DZONE NEWSLETTER

Dev Resources & Solutions Straight to Your Inbox

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.

X

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}