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.
Use a transaction for related operations
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