Mert Özen Further With Every Line
Frontend Browser APIs and PWA — #03

Using IndexedDB in Angular: Schema Versioning, Transactions and an Offline Outbox

Mert Özen Nov 15, 2026 15 min 8 views
Using IndexedDB in Angular: Schema Versioning, Transactions and an Offline Outbox

In IndexedDB the schema changes only inside onupgradeneeded when the version goes up, and a transaction commits by itself at an await that is not IndexedDB; so the network work comes first and the writes after. We show per-version migrations, TransactionInactiveError and another tab blocking an upgrade in Chromium and WebKit, then build the reading list with idb in Angular and an outbox that sends offline reviews without duplicates using idempotency keys.

Version note: The examples in this post were run with Angular 22.2 (TypeScript 6.0), idb 8.0.4, Node.js 24 and Playwright 1.65; browser output comes from Playwright's Chromium 158 and WebKit 27.2 builds. Support data was read from MDN browser-compat-data 8.1.9.

Predict First

The reading list of Bookstore, the online bookstore used as the shared example across this site's series, now lives in IndexedDB. The function below opens a transaction, writes Dune, then fetches Emma's current price from the API and tries to write it into the same transaction:

const dune = { isbn: "9780441172719", title: "Dune", addedAt: "2026-11-14T09:00:00Z" };
const emma = { isbn: "9780141439587", title: "Emma", addedAt: "2026-11-14T09:05:00Z" };

// A transaction commits as soon as it has no pending requests and control returns to the event loop
export async function autoCommitDemo() {
  const db = await openBookstoreDb(2);
  const tx = db.transaction("readingList", "readwrite");
  const store = tx.objectStore("readingList");
  store.put(dune);
  tx.oncomplete = () => console.log("transaction complete");

  const price = await (await fetch("/api/books/9780141439587")).json(); // not an IndexedDB request
  try {
    store.put({ ...emma, price: price.price });
  } catch (err) {
    console.log(`${err.name}: ${err.message}`);
  }
  db.close();
}

What do you see in the console? Are both books written, does the transaction fail and roll back both, or does only one remain? In which order does the "transaction complete" line appear?

In the previous post we decided that the reading list should move out of localStorage into IndexedDB. In this post we first look at IndexedDB's concepts, schema versioning with onupgradeneeded, when transactions close on their own and how another tab blocks an upgrade, using framework-free code. Then we write the same in Angular with the idb library and signals, and build an outbox that sends reviews added offline once the connection is back, without creating duplicates.

Database, Object Store, Index and Key Path

In IndexedDB an origin can have several databases; each database has a version number and contains object stores. An object store is the closest concept to a SQL table, but it has no schema: you write JavaScript objects, Blobs and dates into it (they are copied with structured clone). Every record has a key; the key comes either from a property of the record, called the key path, or from an auto-incrementing number. An index keeps the records of the same store sorted by another property and lets you query by that property. Bookstore's database has two stores: readingList, whose key path is isbn (with a byAddedAt index on the time it was added), and outbox, where offline reviews wait.

The only way to change the schema is to open the database with a higher version. The browser then fires the upgradeneeded event, and creating stores and indexes is possible only inside that event, in a versionchange transaction:

// One function per schema version; a user may jump from any older version to the newest
const migrations = {
  1: (db) => {
    db.createObjectStore("readingList", { keyPath: "isbn" });
  },
  2: (db, tx) => {
    tx.objectStore("readingList").createIndex("byAddedAt", "addedAt");
    db.createObjectStore("outbox", { keyPath: "idempotencyKey" }).createIndex("byCreatedAt", "createdAt");
  },
};

export function openBookstoreDb(version, { onVersionChange } = {}) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("bookstore", version);
    request.onupgradeneeded = (event) => {
      console.log(`upgrade ${event.oldVersion} -> ${event.newVersion}`);
      for (let v = event.oldVersion + 1; v <= event.newVersion; v++) {
        migrations[v](request.result, request.transaction); // runs inside the versionchange transaction
      }
    };
    request.onblocked = (event) => console.log(`blocked: another tab still has version ${event.oldVersion} open`);
    request.onsuccess = () => {
      const db = request.result;
      db.onversionchange = (event) => {
        console.log(`versionchange: ${event.oldVersion} -> ${event.newVersion} requested elsewhere`);
        if (onVersionChange) onVersionChange(db);
      };
      resolve(db);
    };
    request.onerror = () => reject(request.error);
  });
}

Schema Versioning: onupgradeneeded

Writing one migration function per version makes it easy to reason about the version a user comes from. One user opens the app for the first time (0 → 2), another has been on version 1 for years (1 → 2); the loop moves both with the same code. I opened version 1, then version 2, then deleted the database and opened version 2 directly:

export async function upgradeDemo(version) {
  const db = await openBookstoreDb(version);
  const stores = [...db.objectStoreNames].map((name) => {
    const indexes = [...db.transaction(name).objectStore(name).indexNames];
    return indexes.length ? `${name} [${indexes.join(", ")}]` : name;
  });
  console.log(`version ${db.version}: ${stores.join("; ")}`);
  db.close();
}
upgrade 0 -> 1
version 1: readingList
upgrade 1 -> 2
version 2: outbox [byCreatedAt]; readingList [byAddedAt]
version 2: outbox [byCreatedAt]; readingList [byAddedAt]
upgrade 0 -> 2
version 2: outbox [byCreatedAt]; readingList [byAddedAt]

When version 2 was opened the second time, no upgrade ran; the version is already 2. This setup has two rules. First, never change a published migration: if you want to fix version 2's index, add it as version 3, because that code will never run again for users who have already moved to version 2. Second, limit migrations with oldVersion: an expensive step that converts all of version 1's records runs for nothing on an empty store for a user who starts from scratch, but at least it does no harm. In the first attempt for this post I used only an oldVersion < 2 condition instead of the loop; even opening version 1 ran version 2's steps, and on the second open the upgrade aborted with "An index with the specified name already exists". You have to check the target version too.

When Do Transactions Close?

The answer to the Predict First question:

Chromium:
  transaction complete
  TransactionInactiveError: Failed to execute 'put' on 'IDBObjectStore': The transaction has finished.
WebKit:
  transaction complete
  TransactionInactiveError: Failed to store record in an IDBObjectStore: The transaction is inactive or finished.

Dune was written, Emma was not, and the error came from the second put, not from the transaction. IndexedDB transactions commit automatically: when a transaction has no pending requests and control returns to the event loop, it completes. await fetch(...) does exactly that: the function pauses, the event loop moves on to other work, the transaction commits because it has no pending request, and when the fetch returns, the store in your hand belongs to a dead transaction. The same happens while waiting for a setTimeout, an HTTP request or any Promise that is not an IndexedDB request. Waiting for the result of an IndexedDB request is fine, because that request keeps the transaction open.

The fix is to change the order: the network work first, then all writes in one short transaction:

// The fix: do the network work first, then write everything in one short transaction
export async function autoCommitFixed() {
  const book = await (await fetch("/api/books/9780141439587")).json();
  const db = await openBookstoreDb(2);
  const tx = db.transaction("readingList", "readwrite");
  tx.objectStore("readingList").put(dune);
  tx.objectStore("readingList").put({ ...emma, price: book.price });
  await new Promise((resolve, reject) => {
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
  const count = await new Promise((resolve) => {
    const request = db.transaction("readingList").objectStore("readingList").count();
    request.onsuccess = () => resolve(request.result);
  });
  console.log(`saved, reading list has ${count} books`);
  db.close();
}
Chromium: saved, reading list has 2 books
WebKit:   saved, reading list has 2 books

The purpose of a transaction is to make a group of changes atomic. If you need "read and check, ask the network, then write", open a new transaction after the network answer and check the value you read again there; another tab may have changed the data in the meantime.

When Another Tab Blocks the Upgrade

A user has Bookstore open in two tabs. In one, the old version of the app is connected to the database with version 1; the second tab, opened after a new deploy, asks for version 2. The upgrade cannot start until the old connections close. In Playwright I opened two pages on the same origin and tried two cases: tab A's versionchange handler does not close the connection, and it does:

// tab A keeps an old connection open; the handler decides whether it gives way
export async function holdConnection(version, closeOnVersionChange) {
  window.heldDb = await openBookstoreDb(version, {
    onVersionChange: closeOnVersionChange ? (db) => db.close() : undefined,
  });
  return `holding version ${window.heldDb.version}`;
}
--- tab A closes on versionchange: false
tab A: upgrade 0 -> 1
tab A: holding version 1
tab A: versionchange: 1 -> 2 requested elsewhere
tab B: blocked: another tab still has version 1 open
tab A: closing its connection after 1 s
tab B: upgrade 1 -> 2
tab B: version 2: outbox [byCreatedAt]; readingList [byAddedAt]
--- tab A closes on versionchange: true
tab A: upgrade 0 -> 1
tab A: holding version 1
tab A: versionchange: 1 -> 2 requested elsewhere
tab B: upgrade 1 -> 2
tab B: version 2: outbox [byCreatedAt]; readingList [byAddedAt]

In the first case B's request waited with a blocked event and did not move until A closed its connection (one second here; in reality, until the user closes tab A). In the second case A closed its own connection on the versionchange event and B's upgrade ran immediately. The rule is simple: every connection should close itself on versionchange and tell the user to reload the page. Otherwise the app in the new version's tab hangs on startup and the user has no idea what happened. WebKit produced the same order and the same messages.

Readable Code with idb

The raw IndexedDB API is based on events and callbacks; turning each request into a Promise needs small wrappers like the ones above. Jake Archibald's idb library (ISC licensed, 8.0.4) does this with a thin layer: it stays very close to the API itself but returns requests as Promises and lets you type the schema with TypeScript. On the Angular side a service opens the database:

import { Service, signal } from '@angular/core';
import { DBSchema, IDBPDatabase, openDB } from 'idb';

export interface ReadingListItem {
  isbn: string;
  title: string;
  addedAt: string;
}

export interface PendingReview {
  idempotencyKey: string;
  isbn: string;
  rating: number;
  comment: string;
  createdAt: string;
}

interface BookstoreSchema extends DBSchema {
  readingList: { key: string; value: ReadingListItem; indexes: { byAddedAt: string } };
  outbox: { key: string; value: PendingReview; indexes: { byCreatedAt: string } };
}

@Service()
export class BookstoreDatabase {
  // true when another tab opened a newer schema and this tab gave up its connection
  readonly outdated = signal(false);

  private readonly db = openDB<BookstoreSchema>('bookstore', 2, {
    upgrade(db, oldVersion, _newVersion, tx) {
      if (oldVersion < 1) {
        db.createObjectStore('readingList', { keyPath: 'isbn' });
      }
      if (oldVersion < 2) {
        tx.objectStore('readingList').createIndex('byAddedAt', 'addedAt');
        const outbox = db.createObjectStore('outbox', { keyPath: 'idempotencyKey' });
        outbox.createIndex('byCreatedAt', 'createdAt');
      }
    },
    blocking: (_current, _blocked, event) => {
      (event.target as IDBDatabase).close(); // let the other tab upgrade
      this.outdated.set(true);
    },
  });

  connection(): Promise<IDBPDatabase<BookstoreSchema>> {
    return this.db;
  }
}

Thanks to DBSchema, db.put('readingList', ...) rejects an object of the wrong type at compile time, and the index name passed to getAllFromIndex is checked too. blocking is the counterpart of versionchange in the raw API: it closes the connection and sets the outdated signal to true; the app shell watches that signal and shows a "Bookstore was updated in another tab, reload this page" banner. When I opened version 3 in another tab while the app was open:

tab B opened version 3
tab A banner: Bookstore was updated in another tab. Reload this page to continue.

The Reading List in Angular: A Service and resource()

The reading list is a resource(); the loader that reads from IndexedDB returns a Promise, and add and remove call reload() after writing:

import { inject, resource, Service } from '@angular/core';
import { Book } from '../catalog/book';
import { BookstoreDatabase } from '../core/bookstore-database';

@Service()
export class ReadingListStore {
  private readonly database = inject(BookstoreDatabase);

  readonly items = resource({
    loader: async () =>
      (await this.database.connection()).getAllFromIndex('readingList', 'byAddedAt'),
  });

  async add(book: Book) {
    const db = await this.database.connection();
    await db.put('readingList', {
      isbn: book.isbn,
      title: book.title,
      addedAt: new Date().toISOString(),
    });
    this.items.reload();
  }

  async remove(isbn: string) {
    const db = await this.database.connection();
    await db.delete('readingList', isbn);
    this.items.reload();
  }
}

Each operation opens its own short transaction; idb's db.put and db.delete shortcuts do that in one line. If you need to write atomically to several stores, open db.transaction(['readingList', 'outbox'], 'readwrite') and await tx.done; the same auto-commit rule applies there too. On the book page, pressing "Add to reading list" and reloading the page keeps the list in place (the first line of the output below).

An Offline Outbox and Idempotency Keys

A user is on a train with no connection and writes and sends a review of Dune. Instead of rejecting it with an error message, we want to write the review into an outbox store and send it when the connection is back. There are two difficulties. The first is order: reviews should go in the order they were written. The second is duplicates: if a request reaches the server and is saved but the response is lost on the way, the client sends the same review again. The solution to the second is the idempotency key described in the Solution Patterns series post "Idempotency Keys: Not Creating the Same Order Twice on Double Clicks and Retries": every review gets a crypto.randomUUID() when it is written to the outbox and goes with the same key on every attempt. When the mock API sees the same key a second time, instead of creating the record again it returns the first response again with an Idempotency-Replayed: true header.

@Service()
export class ReviewOutbox {
  private readonly database = inject(BookstoreDatabase);
  private readonly http = inject(HttpClient);
  private flushing = false;
  readonly pending = signal(0);

  constructor() {
    window.addEventListener('online', () => void this.flush());
    void this.flush(); // something may have been left from the last visit
  }

  async enqueue(isbn: string, rating: number, comment: string) {
    const db = await this.database.connection();
    await db.put('outbox', {
      idempotencyKey: crypto.randomUUID(), // the same key on every attempt for this review
      isbn,
      rating,
      comment,
      createdAt: new Date().toISOString(),
    });
    await this.flush();
  }

  async flush() {
    if (this.flushing) return;
    this.flushing = true;
    try {
      const db = await this.database.connection();
      // read first, send afterwards: a transaction must not stay open while waiting for the network
      const items = await db.getAllFromIndex('outbox', 'byCreatedAt');
      this.pending.set(items.length);
      if (!navigator.onLine) return;
      for (const item of items) {
        if (!(await this.send(item))) break; // keep this and the later ones, in order
        await db.delete('outbox', item.idempotencyKey);
        this.pending.update((n) => n - 1);
      }
    } finally {
      this.flushing = false;
    }
  }

  // true when the item can leave the outbox
  private async send(item: PendingReview): Promise<boolean> {
    const key = item.idempotencyKey;
    try {
      const response = await firstValueFrom(
        this.http.post(
          `/api/books/${item.isbn}/reviews`,
          { rating: item.rating, comment: item.comment },
          { headers: { 'Idempotency-Key': key }, observe: 'response' },
        ),
      );
      const replayed = response.headers.has('Idempotency-Replayed') ? ' (replayed)' : '';
      console.log(`outbox: ${key.slice(0, 8)} -> ${response.status}${replayed}`);
      return true;
    } catch (err) {
      const status = err instanceof HttpErrorResponse ? err.status : 0;
      console.log(
        `outbox: ${key.slice(0, 8)} -> ${status === 0 ? 'network error, will retry' : status}`,
      );
      // a 4xx will not succeed later either, except timeouts and rate limiting
      return status >= 400 && status < 500 && status !== 408 && status !== 429;
    }
  }
}

flush first reads the whole outbox, then sends each record and deletes the successful one in a separate operation; to avoid the mistake from the Predict First section there is no open transaction while the network is awaited. On a failed send the loop stops, so later reviews do not go before earlier ones. There are three triggers: the online event, the creation of the service (for anything left from the last visit) and every new review.

I played the scenario with Playwright: add Dune to the reading list, reload the page, cut the connection, send two reviews, bring the connection back. I dropped the response of the first POST on the way with Playwright's page.route: the request reached the server and was saved, but the page saw a network error. Then I reloaded the page:

reading list after reload: Dune
offline
2 review(s) waiting for a connection
outbox in IndexedDB: 2
online
(response of the first POST dropped on the way back)
outbox: efa85646 -> network error, will retry
2 review(s) waiting for a connection
outbox: efa85646 -> 201 (replayed)
outbox: 180b6bf0 -> 201
pending after reload: none
[api] #48 POST /api/books/9780441172719/reviews
[api] #48 201 review 505 for 9780441172719 (key efa85646)
[api] #50 POST /api/books/9780441172719/reviews
[api] #50 201 replayed for key efa85646
[api] #51 POST /api/books/9780441172719/reviews
[api] #51 201 review 506 for 9780441172719 (key 180b6bf0)

While offline, the two reviews waited in IndexedDB. When the connection came back, the first attempt created record 505 on the server but its response was lost; the outbox record was not deleted, and to keep the order the second review was not sent either. After the reload the same key went again, the server returned 505's response again instead of opening a new record, and then the second review was saved as 506. The server has two reviews, not three. The same scenario gave the same result in WebKit.

In Production

The online event only says "there is a network interface"; a device behind a captive portal, such as a hotel or airport Wi-Fi login page, looks online but cannot reach the API. That is why the outbox does not delete a record on failure and waits for the next trigger. If the app stays open for a long time, call flush at intervals (or when the page becomes visible again) in addition to online. The flushing flag prevents two flush runs in the same tab, not two tabs sending at once; the idempotency key prevents duplicates in that case too, and a single sender across tabs is possible with Web Locks, the subject of this series' upcoming post on tabs.

Do not keep idempotency keys on the server forever, but do not keep them for less than the longest time a record can wait in the outbox either. When a user comes back after weeks offline, a server that has forgotten the key saves the review a second time. Remember the Safari rule from the previous post too: without interaction for seven days, storage written from script, including the outbox, can be deleted.

Make schema changes part of the deploy process. A version that needs a new store or index can be blocked by open tabs of the old version; without the blocking handler and the banner shown to the user, this comes back as an "the app does not open" complaint. Migrations that move a lot of data (converting version 1's 20,000 records to a new shape, for example) run in the upgrade transaction and the user waits during that time; if possible, convert data lazily, as it is read.

Common Pitfalls

  • Awaiting something that is not IndexedDB while a transaction is open. The transaction commits and the next request throws TransactionInactiveError; do the network work first and collect the writes in one transaction afterwards.
  • Changing the schema without raising the version number. Stores and indexes can be created only inside upgradeneeded; if the version stays the same, the new code never runs for existing users.
  • Editing a published migration. Users who have already moved to that version never get the fix; add the change as a new version.
  • Not listening to versionchange. The tab opening the new version stays in blocked and the user cannot see why it does not open.
  • Writing a large data set in one transaction, awaiting each request one by one. The store stays locked for the whole transaction and other reads and writes wait; fire the requests back to back without awaiting them and await only tx.done, or split the data across several transactions.
  • Skipping a failed record in the outbox and moving on to the next one. The order breaks; an "edit" for the same book can reach the server before the "add".

A Small Experiment

★☆☆ Add a computed called has(isbn) to ReadingListStore and show the button on the book page as "Remove from reading list" when the book is in the list.

★★☆ Add version 3 to the database: a migration that writes a finished: false field into the readingList records. In Playwright, first create a few records with version 2, then open with version 3 and confirm that the old records were converted, and that in a database opened from scratch the migration runs without errors on an empty store.

★★★ Add an attempt count and the time of the last attempt to the outbox; on 5xx responses retry with an exponentially growing wait (1, 2, 4, 8 seconds), and after five attempts show the record to the user as "could not be sent, retry or delete". Add a mode that returns a temporary 503 to the mock API and test the behaviour.

In this post the page itself sent the outbox; nothing happens while the page is closed. In the next post we move to the infrastructure for that, the Service Worker: registration and scope, the install → waiting → activate lifecycle, why a new version waits, and writing cache-first, network-first and stale-while-revalidate strategies by hand in the fetch event.