In this post, I will show how to build a complete working progressive web app (PWA) with angular and ASP.NET Core API. We will look into what a PWA is? why build? why not? how to integrate the default PWA functionality to your existing app, and how to support non-get request methods.

According to Google Documentation a PWA is:

  • ** Reliable:** Instant load and independent to network conditions
  • ** Fast:** Smooth to operate, respond quickly to user actions
  • ** Engaging:** Feels more like a native app in the user device

Codelabs gives a more detailed view of what a progressive web app is and what you can do with it.

In short, progressive web app is a development methodology, that enables a web application to behave like a native app.

So, why to build a PWA from a business perspective?

  • Audience. You can reach way more users by including in your targets mobile users
  • Mobile users can discover a website way more quickly than a mobile native application
  • The cost of getting users to know about your mobile app is higher than presenting them with the same website they use in their computers, which happens to work and behave better in mobile screens

How to consider if your application should be implemented as pwa

As a developer, it's more like a strange habit of trying to view the other side of the coin. Please note that these are my personal thoughts, consider asking the right questions for your app whether to PWA it or not. When deciding to go for the PWA, I think at least the following points should be taken into consideration.

Application size

There are only two hard things in Computer Science: cache invalidation and naming things.

-- Phil Karlton

As a developer: The larger the application codebase is, the harder it becomes to maintain. Now imagine adding to all this a cache logic to every single client that used your app at least once. And think about it, your boss, with two phones and a laptop, is considered 3 clients.

As a user: I would appreciate if your page doesn't abuse my system resources, even if I won't notice it!

What problem does this app solves

If your application is a one time use thing, a landing page, for example, it's not worthy to convert it to a pwa. If that happens to be a media/news type of application you could consider to implement it or not, personally I wouldn't like to host a clone of your newspaper index page in my cache, or the junky carousel scripts for what matters.

Know your user base

Check your analytics, new visitors vs retaining users percentage. PWA is a feature for your retaining users and a problem for onetime visitors.

Technical introduction

We will base this implementation in 2 projects. If you want to follow along get the initial state of the projects here. It's a relatively long article so grab a cup of coffee or something. If not, I'll attach the link to the fully completed project at the end of this article.

  • pwademoapi is the ASP.NET Core web API project
  • pwademo is the angular project

Both of these are created from vs code integrated terminal using the following commands:

dotnet new webapi -n pwademoapi
ng new pwademo

At this point, the project lists, creates and updates items structured like this entity:

public class Person
{
   public int Id { get; set; }
   public string Name { get; set; }
   public string PhoneNumber { get; set; }
   public string Address { get; set; }
}

Convert an already existing angular application to PWA

These are the tools that I'll be using for this demo project:

  • node -v $: v8.9.2
  • ng version $: @angular/cli 7.0.7
  • Lighthouse

Build pwademo application for production, and if you haven't already, install a development http-server package from npm:

ng build --prod
npm i -g http-server

Head into the dist folder and run the app:

cd dist/pwademo
http-server -o

In the newly opened chrome window, hit F12 to open devtools and activate the Audits panel. Here you need to select Progressive Web App option and hit the Run Audit button. This will instruct the Lighthouse tools to audit this application for pwa functionality.

pwa fail list

here is the list of tasks to be completed for this app to be considered a progressive web app. Let's see what is each one of them.

Does not respond with a 200 when offline

This problem, as you can state from the message, is related to a service worker. After we install a service worker it will be his responsibility to respond with a 200 (OK) result even when no network connection is available.

User will not be prompted to Install the Web App

Failures: No manifest was fetched, Site does not register a service worker.

The title is very clear. The app should prompt the user to be installed. This feature of pwa makes the web application live in user home screen and offers benefits similar to a native app. Again this will be solved by a manifest file and a service worker.

Does not register a service worker

Ok, this is already enough, so, what is a service worker? A simple answer would be: A service worker is a script, that will be executed in the background by browsers. However, as you can see from the number of errors it has issued in this audit, we can argue about its simplicity.

Does not provide fallback content when JavaScript is not available

We need to let the user know that javascript is a required ingredient to use this application. To fix this go to your index file and add the following above the closing body tag:

<noscript>
   Javascript is required to run this app. obviously!
</noscript>

"Is not configured for a custom splash screen" and "Address bar does not match brand colors"

Both of these are related to a manifest file, which in turn is just a flat text file structured in JSON that holds some metadata for the application like Icons, name, author and so on.

Implementing PWA

Starting from angular 6, it has become very easy to introduce pwa features to your existing angular application. Make sure your focus in the root of angular project (that's the same level where package.json is located), open your command line and run:

ng add @angular/pwa

This command has:

  • Created icons of different sizes
  • Added a manifest.json file and registered it with index.html
  • Created the service file: ngsw-worker.js
  • Enabled the service worker in angular.json
  • Added the theme color

Now rebuild the application for production

ng build --prod

Run http-server -o in the dist/pwademo folder, and re-execute the Audits from Lighthouse for pwa.

Now as you can see the only thing to be done is implement redirect to https, and that can be achieved by the webservers. So in theory, our application is already complete.

If you try to execute this app with the offline checkbox activated, you will get to see that static assets are cached by default. Let's give this a tune and cache our own get requests to the web API.

ngsw-config.json file is used to instruct the service worker on what to cache. Playing around with this file is enough for all GET requests of your application. visit Google Docs to learn more about all the config options.

Open ngsw-config.json file, after assetGroups array we will add a dataGroups section, now the ngsw-config.json looks like this:


{
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.html",
          "/*.css",
          "/*.js"
        ]
      }
    }, {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/assets/**"
        ]
      }
    }
  ],
  "dataGroups": [
    {
      "name": "pwa-demo-api",
      "urls": [
        "https://localhost:5001/api/**"
      ],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 50,
        "maxAge": "7d",
        "timeout": "10s"
      }
    }
  ]
}

This will tell the service worker to cache everything that comes from our web api, still, try to get the live data, but provide the cached ones if the live data takes more than 10 seconds, and invalidate the cache after 7 days.

Now run the application and make a few tests connected, then turn on the offline mode. If you look inside devtools > Application > Cache Storage, you can view somewhere inside the cache keys that values from web api are being cached.

here is mine:

pwa cache demo

Among all features that pwa offers, one thing that I've found very useful for my projects is the ability to work offline. Unfortunately, only GET requests are supported using the easy way to be cached.

Support POST, PUT and DELETE http verbs

To make this app usable with non-get request methods, we will go through the following path:

  • Create an interceptor that will catch all failing requests
  • if the requests failed with a network error
  • Save it in DB
  • when the connection is available
  • retry db saved requests

Inside the app folder create a directory called Interceptors:

Next, create another ts file error-response.interceptor.ts


import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpErrorResponse} from '@angular/common/http';
import { tap } from 'rxjs/operators';
import { DbContextService } from '../services/db-context.service';
import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs';

@Injectable()
export class ErrResponseInterceptor implements HttpInterceptor {
  constructor(public dbStore: DbContextService, public toasts: ToastrService) { }

  intercept(request: HttpRequest<any>, next: HttpHandler) {
    return next.handle(request).pipe(
      tap(() => {
      }, (err: any) => {
        if (err instanceof HttpErrorResponse) {
          console.log(`Error: ${err.status} is detected.`);
          const req = request.clone();
          if (err.status === 0 && (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE')) {
             this.dbStore.saveRequest(req.urlWithParams, req.method, req.body);
            this.toasts.info('Network connection is not available. This operation will be retried when network is available.');
            return new Observable(() => { });
          }
        }
      }));
  }
}

Create another file called index.interceptor.ts and paste the following:


import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { ErrResponseInterceptor } from './error-response.interceptor';

/** Http interceptor providers in outside-in order */
export const httpInterceptorProviders = [
  { provide: HTTP_INTERCEPTORS, useClass: ErrResponseInterceptor, multi: true }
];

This file, for now, has only one interceptor, but when you add other interceptors in here (such as auth, or log, or whatever you need to interact with HTTP calls in a global manner), this method becomes very handy.

Next thing to do is to register httpInterceptorProviders array in module.ts, for the providers array.

We will use indexedDb to save our failed requests. To use indexed DB from angular/ts I've used 'idb' package. This allows me to operate with the data storage with promises instead of events.

In your cli run:


npm install idb

Update the /app/services/db-context.service.ts to contain the following:


import { Injectable } from '@angular/core';
import idb from 'idb';

@Injectable({
  providedIn: 'root'
})
export class DbContextService {
  dbName = 'pwademo-indexed-db';
  dbTableName = 'failedRequests';
  dbVersion = 1;
  constructor() {
    if (!('indexedDB' in window)) {
      throw new Error('This browser doesn\'t support IndexedDB');
    }
  }

  getId() {
    // creating a genuine GUID in javascript is kind of a bizarre thing,
    // I'm using a simple timestamp for this demo.
    return new Date().getTime();
  }

  idbContext() {
    return idb.open(this.dbName, this.dbVersion, upgradeDb => {
      switch (upgradeDb.oldVersion) {
        case 0:
          upgradeDb.createObjectStore(this.dbTableName, { keyPath: 'clientId' });
      }
    });
  }

  saveRequest(url: string, method: string, body: any) {
    // in case the sync method fails, this method will be
    // invoked again. to prevent duplications of the very same object in localDb,
    // we add a custom property (clientId), and simply return before save
    if (body.hasOwnProperty('clientId')) {
      console.log('this item is already in indexedDb');
      return;
    }

    if (body.hasOwnProperty('description')) {
      body['description'] = `${body['description']} (auto-sync)`;
    }

    const customId = this.getId();
    body.clientId = customId;

    const obj = {
      clientId: customId,
      url: url,
      method: method,
      body: JSON.stringify(body)
    };

    this.idbContext().then(db => {
      const tx = db.transaction(this.dbTableName, 'readwrite');
      tx.objectStore(this.dbTableName).add(obj);
      return tx.complete;
    });
  }

  async getAll() {
    const db = await this.idbContext();
    return db.transaction(this.dbTableName, 'readonly').objectStore(this.dbTableName).getAll();
  }

  async delete(clientId: string) {
    const db = await this.idbContext();
    const tx = db.transaction(this.dbTableName, 'readwrite');
    tx.objectStore(this.dbTableName).delete(clientId);
    return tx.complete;
  }
}

Create a component that we will use to view and sync failed requests:


ng g c dev

Update the dev.component.ts code:


import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { DbContextService } from '../services/db-context.service';

declare var navigator: any;

@Component({
  selector: 'app-dev',
  templateUrl: './dev.component.html',
  styleUrls: ['./dev.component.css']
})
export class DevComponent implements OnInit {
  items: any[] = [];
  connectionInfo: any;
  connectionApiSupport: boolean;

  constructor(private httpClient: HttpClient, private dbContext: DbContextService) { }

  ngOnInit() {
    this.getAll();
    this.connectionCheck();
  }

  connectionCheck() {
    const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
    if (!connection) {
      return;
    }
    this.connectionApiSupport = true;
    this.connectionInfo = connection;
  }

  getAll() {
    this.dbContext.getAll().then(response => {
      this.items = response;
    });
  }

  async delete(id: string) {
    await this.dbContext.delete(id);
    this.getAll();
  }

  retry(item: any) {
    const reqUrl = item.url;
    const reqMethod = item.method;
    const reqBodyObject = JSON.parse(item.body);
    switch (reqMethod) {
      case 'POST':
        this.syncPost(item.clientId, reqUrl, reqBodyObject);
        break;
      case 'PUT':
        this.syncPut(item.clientId, reqUrl, reqBodyObject);
        break;
      case 'DELETE':
        this.syncDelete(item.clientId, reqUrl);
        break;
      default:
        console.warn('CASE METHOD NOT SUPPORTED YET');
        break;
    }
  }

  syncPost(id: string, url: string, body: any) {
    this.httpClient.post(url, body).subscribe(res => {
      this.dbContext.delete(id).then(() => this.getAll());
    }, err => {
      console.log(err);
    });
  }

  syncPut(id: string, url: string, body: any) {
    this.httpClient.put(url, body).subscribe(res => {
      this.dbContext.delete(id).then(() => this.getAll());
    }, err => {
      console.log(err);
    });
  }

  syncDelete(id: string, url: string) {
    this.httpClient.delete(url).subscribe(res => {
      this.dbContext.delete(id).then(() => this.getAll());
    }, err => {
      console.log(err);
    });
  }
}

Update the dev.component.html code:


<div class="col-xs-12 card">
  <div class="card-header">
    Failed requests list
  </div>
  <div class="card-content">
    <table class="table">
      <thead>
        <tr>
          <th>
            Url
          </th>
          <th>
            Method
          </th>
          <th>
            Body
          </th>
          <th>
            &nbsp;
          </th>
        </tr>
      </thead>
      <tbody>
        <tr *ngFor="let item of items">
          <td>{{item.url}}</td>
          <td>{{item.method}}</td>
          <td>{{item.body}}</td>
          <td>
            <button class="btn btn-default" (click)="retry(item)">
              Sync
            </button>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Create a route for this component, and add a link on the homepage.

Now build the application for production env and run it.

To test what we accomplished:

  • Check the offline checkbox in chrome dev tools
  • Click add a new record
  • Complete the form and hit submit
  • Head to the developer page, you should see the saved record here.

Completed checklist

Obstacles I've encountered

  • Service worker cache: Debugging service workers is not exactly the kind of thing you would enjoy spending a lot of time on. The bulletproof solution to this is to use incognito windows. A hard reload and clean cache works too.
  • Connection unavailable: Detecting when a request failed because there is no connection available and the server is out of reach. I've used response code 0 by checking the network tab and assuming that is the right response code to be used, although this is more a failed request than a real response.
  • ng build --prod: You have to build for production after every change because the service worker is not registered on the debug build. This is somehow related to the first point, but it is boring anyway.
  • Maintenance: While it's very simple to work with the default provided options where only the GET requests are cached, maintaining manually a real-life application, that should work offline, becomes a real problem.

I hope you enjoyed reading this article! Visit https://github.com/ermirbeqiraj/pwa-demo to clone the full working application in GitHub. If you have any thoughts in PWA or this article, please feel free to comment below.