================================================================================================================================================================

LocalForage: Performant batch operations

indexeddb javascript localforage typescript

Problem

LocalForage is a thin wrapper around IndexedDB (and some fallbacks). IndexedDB is a NoSQL database API exposed by modern browsers. It allows storing JSON and blobs client-side. LocalForage simplifies interacting with IndexedDB by providing a simple key-value store as API instead. The method names are localStorage-like: getItem(), setItem(), removeItem() etc.

However, batch operations (like a mass insert) are horribly slow when performed via LocalForage. That’s because LocalForage opens for every operation a new IndexedDB transaction and this action is remarkable time-consuming compared with other DBMS. So 1000 setItems() open 1000 transactions which can take multiple seconds to proceed. As LocalForage doesn’t provide the API to do this in a single transaction, you need to write your own thin wrapper around IndexedDB and expose transactions as entity.

Solution

export function kvDb(name: string = 'kvdb') {
  let openedDb: IDBDatabase | undefined

  function idbRequestToPromise<T>(request: IDBRequest<T>) {
    return new Promise<T>((resolve, reject) => {
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  }

  async function openDb() {
    const dbRequest = indexedDB.open(name)
    dbRequest.onupgradeneeded = () => {
      dbRequest.result.createObjectStore('kv-pairs')
    }
    return await idbRequestToPromise(dbRequest)
  }

  return {
    /**
     * A transaction is autocommitted as soon as no new requests are made (!)
     * All requests must be added in the same cycle of the event loop.
     * The transaction cannot be used afterwards.
     */
    async transaction() {
      if (!openedDb) openedDb = await openDb()
      return openedDb.transaction('kv-pairs', 'readwrite').objectStore('kv-pairs')
    },

    async get<T>(key: string, transaction?: IDBObjectStore): Promise<T> {
      const store = transaction ?? (await this.transaction())
      const value: T | undefined = await idbRequestToPromise(store.get(key))
      if (value === undefined) throw new Error(`db "${name}" key "${key}" not found`)
      else return value
    },

    async getDefault<T>(key: string, defaultValue: T, transaction?: IDBObjectStore): Promise<T> {
      const store = transaction ?? (await this.transaction())
      const value: T | undefined = await idbRequestToPromise(store.get(key))
      if (value === undefined) return defaultValue
      else return value
    },

    async set<T>(key: string, value: T, transaction?: IDBObjectStore) {
      const store = transaction ?? (await this.transaction())
      await idbRequestToPromise(store.put(value, key))
    },

    async delete(key: string, transaction?: IDBObjectStore) {
      const store = transaction ?? (await this.transaction())
      await idbRequestToPromise(store.delete(key))
    },

    async clear(transaction?: IDBObjectStore) {
      const store = transaction ?? (await this.transaction())
      await idbRequestToPromise(store.clear())
    }
  }
}

As additional optimization the browser can be asked to persist the storage as long as possible:

try {
  await navigator.storage?.persist?.()
} catch (e) {
  console.error('navigator.storage.persist() failed:', e)
}

Usage example

const localCache = kvDb('cache')

// Without transaction (slow on batch):

await localCache.clear()  // clear cache
await localCache.set('object1', {hello: 'world'})
await localCache.get('object1')  // = {hello: 'world'}
await localCache.getDefault('object2', 'fallback')  // = 'fallback'
await localCache.delete('object1')


// With transaction (fast on batch):

const transaction = await localCache.transaction()

await Promise.allSettled(
  Array.from(Array(1000).keys()).map(
    async i => localCache.set(`file-${i}`, await loadFile(i), transaction)
  )
)

Gotchas: A transaction can only be used once with Promise.all() or Promise.allSettled(). Using the same transaction in multiple consecutive statements is not possible (thanks to the weird IndexedDB design).