Skip to content
Blog

IndexedDB for Flutter Web: Structured Local Storage That Scales

Store structured, queryable data in Flutter web with IndexedDB, using package:web, idb_shim, or a full Drift database compiled to Wasm.

Published on • November 10, 2026

AI Assistant

localStorage runs out of ideas fast: it is string-only, synchronous, and capped in the low megabytes. IndexedDB is the browser’s answer for real application data, storing structured records in asynchronous transactions with indexes and cursors, scoped per origin. In Flutter web you can reach it three ways, from raw package:web bindings up to a full SQL database compiled to WebAssembly, and this post walks the ladder.

Why IndexedDB instead of localStorage

IndexedDB databases hold object stores, similar to tables, each record with a key, while localStorage stores only string key-value pairs. IndexedDB reads and writes are asynchronous and non-blocking, so the UI thread keeps rendering during a large save, and the API supports transactions, indexes, cursors, and key ranges for querying rather than dumping everything into memory. Data is scoped to the origin, so the same app served from two different ports, for example localhost:8080 and localhost:8081, is two separate databases, and browsers apply a per-origin storage quota with eviction policies that can clear data under pressure.

The main code-level pitfalls are worth internalizing before you write anything: a transaction auto-commits if you await an unrelated future between operations, so keep each transaction’s awaits inside its own API calls; and IndexedDB can only store structured-cloneable values, not arbitrary custom class instances, so store plain maps or objects with explicit fields.

Raw access through package:web

The lowest layer is window.indexedDB from package:web, which exposes the IDBFactory. Calling open(name, version) returns an IDBOpenDBRequest that fires upgradeneeded when the database is new or the version increased, blocked when another tab holds an older version open, and success when the connection is ready.

import 'dart:js_interop';
import 'package:web/web.dart' as web;

void openNotesDb() {
  final request = web.window.indexedDB.open('notes-db', 1);

  request.onupgradeneeded = ((web.Event _) {
    final db = request.result as web.IDBDatabase;
    if (!db.objectStoreNames.contains('notes')) {
      db.createObjectStore('notes',
          web.IDBObjectStoreParameters(autoIncrement: true));
    }
  }).toJS;

  request.onsuccess = ((web.Event _) {
    final db = request.result as web.IDBDatabase;
    // Use the connection for transactions
  }).toJS;
}

This works, but you end up writing callback plumbing for every transaction, which is why most Flutter code sits on a wrapper.

idb_shim: one API for web and native

idb_shim gives you a Dart-flavored IndexedDB API that runs on the browser and on mobile or desktop, so the same code can persist data across platforms. Version 2.11.x ships two surfaces: the idb API that mirrors IndexedDB itself, with IdbFactory, idbModeReadOnly/idbModeReadWrite, onUpgradeNeeded, txn.completed, and helpers like add, put, getObject, getAll, and openCursor; and the newer sdb API, a simpler typed layer over the same storage. For tests, newIdbFactoryMemory() gives you an in-memory factory with no browser involved.

import 'package:idb_shim/idb_browser.dart';

Future<void> saveNote(String title, String body) async {
  final factory = idbFactoryWeb; // sdbFactoryWeb for the typed API
  final db = await factory.open('notes-db', version: 1,
      onUpgradeNeeded: (event) {
    final store = event.database.createObjectStore('notes',
        keyPath: 'id', autoIncrement: true);
    store.createIndex('by_title', 'title');
  });
  final txn = db.transaction('notes', idbModeReadWrite);
  await txn.objectStore('notes').put({'title': title, 'body': body});
  await txn.completed;
  await db.close();
}

On native platforms, pair the same interface with idb_sqflite so mobile gets SQLite-backed storage instead of a browser. Note the Flutter team’s own guidance for the --web migration flag: dart:indexed_db was removed from the SDK, and the supported replacement on web is idbFactoryWeb.

A full SQL database: Drift on Wasm

When you want SQL rather than key-value stores, Drift compiles SQLite to WebAssembly and runs it in the browser. Setup means dropping two artifacts into your web/ folder, sqlite3.wasm and drift_worker.dart.js, then opening the database from Dart; the worker must be served with Content-Type: application/wasm, and the highest-performance storage backend (OPFS) needs COOP and COEP headers, since it relies on SharedArrayBuffer.

import 'package:drift/wasm.dart';

Future<void> openWebDb() async {
  final db = await WasmDatabase.open(
    databaseName: 'notes',
    sqlite3Uri: Uri.parse('sqlite3.wasm'),
    driftWorkerUri: Uri.parse('drift_worker.dart.js'),
  );
  // db.resolvedExecutor is ready for queries
}

Drift picks a storage backend by capability, trying in order shared OPFS, locked OPFS, shared IndexedDB, plain IndexedDB, and finally an in-memory database, so the same code degrades gracefully when OPFS or cross-origin isolation is unavailable.

Write, read, and query a store

Whatever layer you choose, the pattern is the same: open a transaction, do all of its work, await that transaction’s completion, and only then move on. Reading back with an index or a key range is where IndexedDB beats a flat key-value store.

import 'package:idb_shim/idb_browser.dart' as idb;

Future<List<Map<String, Object?>>> recentNotes(idb.Database db) async {
  final txn = db.transaction('notes', idb.idbModeReadOnly);
  final index = txn.objectStore('notes').index('by_title');
  final all = await index.getAll(null); // full scan via index
  await txn.completed;
  return all.cast<Map<String, Object?>>();
}

Keep transactions short, avoid awaiting anything that is not part of the transaction, and prefer put over add when a record may already exist so a duplicate key does not throw.

Test the storage story

Verify in Chrome DevTools under the Application tab: expand IndexedDB, confirm your database, object stores, and indexes exist, and watch the storage usage figure. Then test the ugly paths: fill the quota to see eviction handling, open the app in two tabs at once to hit blocked during an upgrade, and reload offline to confirm data is still there. For logic bugs, run the same code against newIdbFactoryMemory() in a Dart test so transaction-ordering mistakes surface without a browser.

Takeaway: Use IndexedDB for structured Flutter web data, pick your layer deliberately, package:web for control, idb_shim for a portable API, or Drift on Wasm for SQL, and keep every transaction’s awaits inside itself.

Sources