Storage and browser security

Dive into IndexedDB

Learn IndexedDB with the idb promise wrapper: create a database, store and query structured data, use transactions, and upgrade schemas.

IndexedDB is the browser’s asynchronous database. It stores objects, arrays, dates, files, blobs, and binary data, not just strings.

Reach for it for offline data, cached documents, drafts. For a color theme, localStorage is simpler.

The native API is event-based and verbose. I use the small idb library, which wraps it in promises while keeping every IndexedDB concept intact.

Install idb

npm install idb

Import what you need:

import { deleteDB, openDB } from 'idb'

IndexedDB doesn’t exist during server-side rendering. Open the database only in browser code.

Create a database and object store

Open a database with a name and a version:

import { openDB } from 'idb'

const db = await openDB('notes-app', 1, {
  upgrade(db) {
    db.createObjectStore('notes', {
      keyPath: 'id',
    })
  },
})

An object store is the equivalent of a table. Here each note’s id property is its key.

upgrade() runs when the database is created and every time you open it with a higher version. Schema changes go there and nowhere else. Don’t await a network request inside it.

Add or replace data

add() fails if the key already exists:

await db.add('notes', {
  id: 'note-1',
  title: 'Shopping list',
  body: 'Milk and bread',
  updatedAt: new Date(),
})

Call it again with note-1 and it rejects. put() inserts or replaces:

await db.put('notes', {
  id: 'note-1',
  title: 'Shopping list',
  body: 'Milk, bread, and coffee',
  updatedAt: new Date(),
})

Values are structured-cloned, so functions and DOM elements can’t go in.

Read data

Get one value by key:

const note = await db.get('notes', 'note-1')

if (note) {
  console.log(note.title)
}

get() returns undefined for a missing key. Get everything:

const notes = await db.getAll('notes')

On a big store, don’t do that blindly. Add an index and query only what you need.

Add an index

An index lets you query by another property. Indexes are schema, so they’re created in upgrade():

const db = await openDB('notes-app', 2, {
  upgrade(db, oldVersion, newVersion, transaction) {
    if (oldVersion < 1) {
      db.createObjectStore('notes', {
        keyPath: 'id',
      })
    }

    if (oldVersion < 2) {
      const store = transaction.objectStore('notes')
      store.createIndex('by-updated-at', 'updatedAt')
    }
  },
})

Then query through it:

const notes = await db.getAllFromIndex('notes', 'by-updated-at')

Notice the oldVersion checks. A new user runs both blocks, a user on version 1 runs only the second. Never assume everyone is on the previous version.

Shortcuts like db.put() create a transaction per operation. When several changes must succeed or fail together, open one read-write transaction:

const tx = db.transaction('notes', 'readwrite')
const store = tx.objectStore('notes')

await store.put({
  id: 'note-2',
  title: 'Ideas',
  body: 'Build an offline notes app',
  updatedAt: new Date(),
})

await store.delete('note-1')
await tx.done

tx.done resolves when the transaction commits and rejects if it aborts. If one request fails, none of the changes are kept.

Be careful: don’t fetch() in the middle. When control returns to the event loop with no pending database request, the transaction goes inactive and your next request throws.

Delete records

One record:

await db.delete('notes', 'note-2')

Every record, keeping the store:

await db.clear('notes')

The whole database:

db.close()
await deleteDB('notes-app')

Another open tab can block an upgrade or a deletion. Close the old connection when a newer version asks:

const db = await openDB('notes-app', 2, {
  upgrade(db, oldVersion, newVersion, transaction) {
    // Apply versioned schema changes here.
  },
  blocking() {
    db.close()
  },
})

Then ask the user to refresh.

Check whether a store exists

objectStoreNames is list-like, not a function:

if (db.objectStoreNames.contains('notes')) {
  console.log('The notes store exists')
}

Creating or deleting stores only works inside upgrade().

Storage limits and persistence

Capacity and eviction vary by browser, device, and free disk space. Important user data needs a server sync. You can ask for an estimate:

const estimate = await navigator.storage.estimate()

console.log(estimate.usage)
console.log(estimate.quota)

navigator.storage.persist() requests persistent storage, but the browser decides.

More in MDN’s IndexedDB API overview, Using IndexedDB, and storage quota and eviction criteria.

Lesson completed