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.