Runtime Errors in PWAs: Risk Surface and Mitigation

PWAs can be resilient solutions thanks to their static asset nature, but they are not immune to runtime errors.

When they occur, these errors can compromise data consistency or disrupt the user experience, thus the need for mitigation.

So, How do Runtime Errors Come to Be?

In general, we could say that somehow our program is failing our assumption and our expectation.

But, what are those assumptions and expectations, you may ask.

To discover them, we have to look at the very foundations of our program: functions and data.

Are in fact, these two pillar that escape our control and intuition.

Let’s see how.

JavaScript Unsupported APIs

The first case involves a function, a missing function to be precise.

Thanks to packages like caniuse-lite and Browserlist, it is possible to use cutting-edge JavaScript APIs while bundlers take care of the artifact size.

However, this approach doesn’t guarantee that there will be a polyfill in place when a browser hit your PWA.

And when a not supported browser does hit your app, then it’s when you have a runtime error.

An unsupported JavaScript API runtime error.

But it doesn’t stop there.
Even specific devices or browser versions present specific challenges.

For example, on mobile, you may have troubles intercepting a keydown event, while on Safari, you may have troubles with the localStorage.

So, how to mitigate?
In these cases, the mitigation consists of planning ahead, listing browsers and devices you must support and tuning tools and polyfills accordingly.

During investigation, take extra care with the packages you’ll use as well.

In particular, when the package patch missing functionality in the browser, like for PDF or Barcode reader, there is the risk these package will skip the polyfill step, using a WebWorker directly.

// configure browserslist
// in package.json
{
    .
    .
    .
    "browserslist": [
       "Chrome >= 120",
       "Safari >= 18",
    ]
}

// check browsers targeted
➜ npx browserslist

JavaScript Unsupported APIs - Mitigation:

  1. state devices and browsers you must support then,
  2. setup target key in tsconfig.json see
  3. setup browserslist key in package.json see
  4. state key packages and investigate errors in browser or device, in particular if related to keyboard, audio, PDF, wasm.

Value Level Invalid States

For the second case, we have a function again.
A lying one, this time.

When we reason about a function, we play a simulation in our mind. Given an input of type A, my function will retuns me a value of type B, we tell ourselves.

That seems a plain and fair obviety. But we should better think: will my function always return me a value of type B for any given input? and, if it does, is it correct? is it correct that it always returns me the same type?

This unexpected type is our runtime error.

In fact, the unexpected returned type by our function is now making the rest of our code crumble. That parseInt that returned a NaN has set us up.

But how it comes? And why don’t we receive the right value if such value exists at all?

Well, everything starts with good intents unfortunately.
In this case started with the need to save memory.

As back in the days memory was scarce, people tried to use as little memory as possible.

So, C developers started having a special values (sentinel value) to represents special states.

For example, you’re looking for the index of an element in an array. What about if the element is not in the array at all? You’ll receive -1.

How it comes?

Well, -1 can not be a legit index as the lower index in an array is 0, thus -1 as element not found make kind of sense.

We just encoded an Invalid State, our element not found, at the Value Level, that is -1.

It was convenient in a way, as the function was always returning a number, you didn’t have to fight too much with the types, -1 was light in memory, quite obvious too in a way.

But take this pattern to our days, add a touch of TypeScript and you may think that a case like const a: number = parseInt(""); is perfectly fine.

Except it’s not, NaN was not what we were thinking when we were looking for a valid number.

So, how can we mitigate this time?

Being aware of the pattern helps already.

In addition, avoiding using primitive function like parseInt and replace them with a runtime validation library will help too.

You want to be sure that if you have a signature that state number you indeed receive a number.

Also, avoid the Invalid States at the Value Level as well in the functions you’re writing.

Use an Algebraic Data Type instead.

If you have a function that may return a value or none, model it using different types. Make it explicit. TypeScript will be effective with ADT, the type system will be able to track error cases too, not only success cases.

For inspiration with ADT, see Return from Rust, or MayBe from Haskeller as example.

Value Level Invalid States - Mitigation:

  1. wrap primitive function like parseInt or use a validation coercion library like type.

  2. when dealing with Object try to have a schema or use a Map if possible, which has map.has(key).

  3. in general, be aware of the sentinel value side effects and if in need use an Algebraic Data Type.

External Input Sanitisation

For the third and last case instead, we have data.
Untrusted data.

Most likely, your PWA will consume data coming from the outside world. That is from a form, an API, a file, and most likely you’ll have an interface in TypeScript that will give you the illusion of receiving the data in a particular shape.

Do not trust that illusion, do not trust the external source of data.

For each external data source, parse and create your own version of the data.

I repeat: do not trust the external source of data.

Even if TypeScript lets you go with a return response.json() as Promise<T>; or similar, you parse and create your own version of the data.

You have no guarantee that when you use a REST API will indeed get the value you are supposed to receive.

// Small example creating your own version of the data

fetch("/api/banana")
    .then(r => r.json())
    .then(data => sanitizeData(data.banana)) <<< parsing or data manipulation 
    .then(banana => new Banana(banana))      <<< instantiate own version of object

External Input Sanitisation - Mitigation:

  1. assess all possible external data sources: forms, files, query params from url, web storage

  2. parse and create your own version of the data

  3. use runtime validation lib like type

  4. avoid the as keyword as much as you can; if possible, use eslint@no-restricted-syntax.

Consideration

In general, planning ahead helps a lot.

Especially because, as you’ll state browsers to support, dependencies and external data ingestion, you’ll automatically start thinking about the related solutions.

Tools we use, like JavaScript, TypeScript, Bundlers, WASM, Polyfills are usefull but are no silver bullet either, and each of them has its own pitfalls.

If you are interested in strengthening even further your application, I would highly reccomend reading Thoughts on Architecting LARGE software projects, the Front End perspective and Thoughts on broken vs wrong.