Memoizing with WeakMaps

I stumbled upon a performance bottleneck while building a small progressive web app recently. Turns out that Date#toLocaleDateString has terrible performance in Mobile Chrome. This method allows granular control over how a JavaScript Date instance is turned into a human readable string. It is a relatively new feature and in Mobile Chrome it might be as slow as ~100 operations per second.

Does not sound extremely bad but other browsers do several thousand operations in a second. My use case is fetching data containing lots of dates from a server and then formatting them for humans. Certain user interactions result in redrawing the screen and calling toLocaleDateString about 150 times. That results in more than a second delay in Mobile Chrome, definitely not a great user experience.

Time for some optimization! One way would be not using toLocaleDateString and formatting dates “manually” but doing so is more complicated than it sounds. I decided to memoize the formatted strings.

This is how the original function looked like:

function formatDate (date) {
  return date.toLocaleDateString('en-US', {
    month: '2-digit',
    day: '2-digit'
  })
}

Memoizing means, given an input, instead of some expensive computation, return a cached result. We will use WeakMaps to cache the formatted strings.

WeakMap allows storing values by keys where keys are JavaScript objects and the value can be anything. WeakMaps are special because the entries are “removed” whenever the key object is garbage collected. Perfect fit for our caching needs!

The memoized version of the function looks like this:

const dates = new WeakMap()

function formatDateMemoized (date) {
  let formatted = dates.get(date)
  if (!formatted) {
    formatted = date.toLocaleDateString('en-US', {
      month: '2-digit',
      day: '2-digit'
    })
    dates.set(date, formatted)
  }
  return formatted
}

This runs about 24k times per second if the value is already cached. As usual with caching, the performance improvement depends on the rate of cache hits and misses. For my use case this worked fine: the first render after a network response is slow (but talking to the server tends to be slow anyway) and then subsequent rerendering of the same data is very fast. The date instances are garbage collected and the cache is cleaned up when the next server response is parsed.

There is a gotcha though. Even though caching formatted dates works for my use case, this approach is dangerous to generalize.

Firstly, Date in JavaScript is mutable. That means the same date instance might represent 2019-01-05 16:00 at some point and 1970-02-23 10:42 in some other point as the application runs. There is no way to prevent this and if you ever modify the date instances before formatting them, you will end up with hard to debug issues.

Secondly, two different instances of Date might represent exactly the same point in time which means we should use a single cached result for both but we will not because they are cached under different keys.

The only way to prevent bugs resulting from this other than “making sure” Dates are not modified (which relies on humans not making mistakes) is to always use immutable objects as WeakMap keys. For dates Day.js is an option, for other data types Immutable.js is a good start.

A long term solution for my problem would be Chrome fixing the performance issue though :) In case someone wants to repeat it I published the benchmark here.