PWA that polls the server never updates to a newer version

Viewed 278

We have an active version of our app which starts polling the server right away to get some basic info - when the request fails, it retries after a second until the request succeeds.

In the newer version, we have removed this polling: i.e. the old endpoint does not exist anymore.
The problem now is, that the PWA never updates to the newer version: i.e. when a user, who has already opened the old version before, opens the app, they will see the old version that constantly tries to poll the server (and this request fails of course). But the angular service worker never updates to the new version (i.e. refresh does not work, closing the browser tabs, or the whole browser does also not work).

Note: The issue seems to be related to the polling

  • When we have an app version that polls the server successfully (i.e. polling stops), the app will be updated as expected
  • When we have an app version that does not poll the server, the app will be updated as expected
  • But in our case, we have an app that polls the server and polling constantly fails (and will be retried every second): In this case, the app is never updated!

The only workaround is to press CTRL+F5 to hard-reload the app.
But this is of course not a solution because average users don't know that they can/should do this...

Any idea how to solve this issue?

  • We need a solution that can be done on the server: i.e. it's not possible to tell every user to press CTRL+F5 or uninstall the app via dev-tools, etc.
  • A temporary nasty workaround for now is that we reimplemented the endpoint (that the old versions poll) and return some valid dummy data

Reproduction and details

Here is a test-app to reproduce the issue: https://github.com/tmtron/pwa-test
The readme file contains full details.

Initial state:

We have started the app before and the browser has the version of the PWA that constantly polls the server and fails. Now we close the browser tabs (that show our app)

  • Next: build and serve a new app version
  • When we open the browser it will still serve the previous app-version 2 (as expected)
  • since this is a navigation request the browser will check for updates
  • when we watch the terminal of the http-server - after some seconds it will show that the browser requests a new version of the ngsw-worker.js
      [2020-12-17T11:03:23.948Z]  "GET /ngsw-worker.js" ...
    
  • but it does not get the new application version
  • When we now refresh the tab (press F5), the OLD app version will still be active!
  • Closing the browser window and opening again (with the timer enabled) will not help
    • we still see the OLD version - refresh won't work

NGSW state

http://127.0.0.1:8080/ngsw/state

NGSW Debug Info:

Driver state: NORMAL ((nominal))
Latest manifest hash: b5a9f50d4efae604dbc6706040c71ecb2029c1cf
Last update check: never

=== Version b5a9f50d4efae604dbc6706040c71ecb2029c1cf ===

Clients: 4cad0ef1-179e-4629-b20f-602c4a4be1f9, 4d82d618-8fac-4e79-938c-605266702fa1, e0813c6a-3b76-4aeb-a14a-9043f0417b24, 9bf1be36-e911-4612-8158-21819eb222dc, 09337bd0-d060-46a1-b9e9-56257b879bdd, 0ce1327b-05bb-44e1-bba7-f3769cbad9b2, c9b796dd-b20c-417b-a504-7f065fd841db

=== Idle Task Queue ===
Last update tick: 1s996u
Last update run: never
Task queue:
 * init post-load (update, cleanup)
 * initialization(b5a9f50d4efae604dbc6706040c71ecb2029c1cf)
 * check-updates-on-navigation

Debug log:
  • I am not sure why there are so many Clients - the app is only opened in one tab and the ngsw/state tab is open
  • It seems that the task queue never changes

app-component that starts the timer:

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'pwa-update-test-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {
  private timerId?: number;
  status = 'uninit';

  version = 1;

  timerActive = true;

  constructor(private readonly httpClient: HttpClient) {}

  ngOnInit() {
    this.status = 'waiting';
    const timerUrlParamIsPresent = window.location.href.indexOf('timer') >= 0;
    this.timerActive = timerUrlParamIsPresent
    if (this.timerActive) {
      this.timerId = window.setTimeout(() => this.getAppInfo());
    } else {
      this.status = 'timer deactivated via URL parameter';
    }
  }

  getAppInfo() {
    this.status = 'working';
    this.httpClient
      .get(window.location.origin+'/non-existing-end-point')
      .toPromise()
      .catch((err) => {
        this.status =
          new Date().toLocaleTimeString() + ' failed! (will retry soon)';
        console.log('getAppInfo failed', err);
        if (this.timerActive) {
          this.timerId = window.setTimeout(() => this.getAppInfo(), 2000);
        }
      });
  }

  stopTimer() {
    this.timerActive = false;
    clearTimeout(this.timerId);
  }
}

ngsw-config

{
  "$schema": "../../node_modules/@angular/service-worker/config/schema.json",
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.html",
          "/manifest.webmanifest",
          "/*.css",
          "/*.js"
        ]
      }
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/assets/**",
          "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
        ]
      }
    }
  ]
}
1 Answers

Posting the same answer I posted on GitHub for visibility.


First of all, thx for the very detailed reproduction, @tmtron ❤️
It really helped identify the issue.

So, here is what happens:
In order to avoid interfering with the application requests, the SW will not immediately check for updates on initialization and on navigation requests. Insted it will schedule a task to check for updates when "idle". Currently, the SW is considered idle when it hasn't received any request for 5 seconds.

So, since your app is constantly polling (i.e. sending requests), the SW will never consider itself idle and therefore will never perform the check-for-updates task.

Note: This is not related to the requests failing or succeeding. It just happens that when the request fails, your app will send a new one before the 5s idle threshold.

Despite the fact that it is probably not a good idea for an app to constantly make the same request when it fails (for example, there should be some backoff mechanism), the SW should be able to handle that more gracefully :grin:

For example, the IdleScheduler could have a max delay (say 30s) after which it executes its pending tasks even if the SW is not considered "idle".

As far as work-arounds go, there are several things one could do on the client to avoid this issue:

  1. Call SwUpdate#checkForUpdate() to trigger an immediate check for updates. This does not go through the IdleScheduler, so it is not subject to the issue.

  2. Use ngsw-bypass on the offending request to bypass the SW (which means that the recurring request would not prevent the SW from being considered idle).

  3. Avoid polling at a constant rate when the request fails and implement some sort of exponential backoff.

Of course, none of these will help you if you can't update your client code (because the SW keeps serving the old version)

I am afraid, the only thing you can do from the server is return a 2xx response for the polling request (so that the app stops polling and the SW can update itself.

Alternatively (or in addition) you could configure the server to include the Clear-Site-Data header (with the "storage" or "*" directives) for the removed endpoint, so that old clients who make that request will have their SW unregistered by the browser.

Related