Converse is a browser-based XMPP chat client that I’ve been working on. It stores lots of user-data in the browser, so much data, that users were regularly running into the localStorage limit of about 10MB.
Luckily there is an alternative storage solution in modern browsers, namely IndexedDB, which doesn’t have a fixed size limit.
IndexedDB has a very different API than localStorage, and it’s also asynchronous, so updating Converse to use it would be a big task.
Luckily I found the excellent localForage library, which provides a unified API for storing data in either localStorage or IndexedDB (including other potential backends such as sessionStorage).
localForage helped a lot, but it still took a long time to add Converse support for IndexedDB (via localForage), mainly because of its asynchronous API. The Converse code (and its test suite) was structured around quick synchronous writes to localStorage, so there were implicit assumptions in the code that were now broken.
Eventually I managed to get Converse to use localForage, so that the user could choose which persistent store they’d like to use, sessionStorage, localStorage or IndexedDB.
Unfortunately it soon became clear that Converse was painfully slow when using IndexedDB for moderate to heavy chatting.
Turns out the issue is that IndexedDB’s writes are slow, and even though they were asynchronous, they were still slowing the app down. The reason for this is because with XMPP, you can’t process subsequent messages until processing of previous ones have been finalized, otherwise you can’t handle messages that refer to earlier ones, such as corrections or retractions.
So Converse was first waiting until a message object was created (and persisted), before handing the next incoming message.
When you opened a chat room, you’d get a 100 messages, but they’d appear slowly as Converse waits for IndexedDB writes to complete before processing each subsequent message.
It was clear that the number of writes to IndexedDB had to be reduced, but I wasn’t sure how. So I left the problem and moved on to other things, believing that I’ll eventually think of (or stumble upon) a solution.
Eventually I did stumbled upon a partial solution in the form of the localForage-setItems plugin, which lets you write write multiple key-value pairs in a single transaction, something which standard localForage doesn’t support.
This was useful because while debugging the issue I noticed that whenever a
message was created, two localForage setItem
calls were made. Once to add the
message to the collection of messages belonging to the chat room, and once to
save the attributes of the actual message.
With localForage-setItems
I could collapse these two calls into a single one.
The second part of the puzzle came months later, when I had an insight while thinking about reducing HTTP calls in an app. Multiple function calls could be collapsed into one, if the parameters for each call were objects that could be merged into a single object.
Debouncing lets us reduce a number of successive function calls into a single call, but only the parameters of the final call of the debounced function are ever passed to the inner executing function. Earlier passed in parameters are lost.
If we could create a special kind of debounced function that merges the parameters of each call, then we could in essence batch multiple function calls into a single one, without losing any final data compared to executing all the calls separately.
I set out to create such a function, and called it mergebounce.
It uses lodash’s debounce and merge
functions to create a mergebounce
function.
I then realized that this function, together with localForage-setItems
could solve
the IndexedDB-related slowness in Converse, because the data that was being
saved was objects that could be merged into one and then persisted with a
single write.
So I set about adding mergebounce to the storage-layer of Converse.
What I did, was to use mergebounce to create a wrapper function of
localForage-setItems
that intercepts all calls and after 50ms of inactivity
calls the inner function with a single merged object from all the individual
calls.
Like debounce, mergebounce has a flush
method which can be used to
immediately execute the inner bounced function. I added event handlers to flush
all writes when the user logs out of the app, and also when the tab unloads.
There were a few other kinks to iron out, but I’m happy to report that it turned out to be a big success.
The effect on performance was drastic and immediately noticeable.
I also did some rudimentary profiling. Without mergebounce, I counted over a hundred and twenty IndexedDB writes when logging into Converse with my personal XMPP account. With mergebounce, this dropped to about 17. An improvement of almost a factor of ten.
Truth be told, I’m kind of suprised that I haven’t seen something like mergebounce before. Maybe it’s out there and I just missed it. In any case, I’m quite chuffed at having cracked this nut.
This improvement has been merged to the master
branch of Converse and will be included
as part of the upcoming 8.0.0 release.
Since then I’ve also used mergebounce to reduce HTTP calls to fetch resources in a different project and I expect to reach for it again in the future.
Pehaps it can be of value to others as well.
Feel free to reach out to me via my site’s contact form if you’d like to know more.