Skip to content
Blog

localStorage in Flutter Web: Persisting State with dart:js_interop

Reach window.localStorage from Flutter web with package:web, handle the 5 MiB quota, and know when shared_preferences or IndexedDB fits better.

Published on • November 5, 2026

AI Assistant

Flutter web apps often need to remember a token, a theme choice, or the last opened project without a backend. Browser localStorage is the simplest answer — and on Flutter web you reach it today through dart:js_interop and package:web, not the deprecated dart:html.

Getting to window.localStorage today

dart:html is deprecated, isn’t supported when compiling to Wasm, and is being replaced by package:web (current docs show web 1.1.2), which is generated from the Web IDL on top of dart:js_interop — see the package:web migration guide. Add the dependency and import it:

dependencies:
  web: ^1.1.0
import 'package:web/web.dart';

final Storage storage = window.localStorage;
storage.setItem('theme', 'dark');
final theme = storage.getItem('theme'); // String? — null when missing
storage.removeItem('theme');

The Storage extension type exposes exactly the Web Storage API: getItem(String key) -> String?, setItem(String key, String value), removeItem, clear, key(int index), and length. package:web even ships StorageGlue, so the index operators work too: storage['theme'] = 'dark' and storage['theme'].

Storing structured data: JSON in, JSON out

Storage only holds strings, so encode objects yourself:

import 'dart:convert';
import 'package:web/web.dart';

class WebPrefs {
  static final Storage _store = window.localStorage;

  static Future<void> saveSession(Map<String, dynamic> data) async {
    _store.setItem('session', jsonEncode(data));
  }

  static Map<String, dynamic>? loadSession() {
    final raw = _store.getItem('session');
    if (raw == null) return null;
    return jsonDecode(raw) as Map<String, dynamic>;
  }

  static void clear() => _store.removeItem('session');
}

Keep it synchronous and small: localStorage operations block the main thread, so reading a megabyte-long string on every frame is a jank generator.

Quotas, QuotaExceededError, and private mode

MDN’s storage quotas guide states that Web Storage is capped at 10 MiB total per origin — 5 MiB of local storage and 5 MiB of session storage. Overflowing it throws a DOMException named QuotaExceededError, which you must catch:

bool write(String key, String value) {
  try {
    window.localStorage.setItem(key, value);
    return true;
  } catch (e) {
    // DOMException: QuotaExceededError — quota full (or storage blocked).
    if ('$e'.contains('QuotaExceededError')) {
      _evictOldestEntries();
      return false;
    }
    rethrow;
  }
}

Two traps from MDN’s Web Storage guide worth handling explicitly:

  • Feature detection must be behavioral. Some browsers expose window.localStorage but disable it — a private-mode document can get an empty object with a quota of zero. Probe with a real write/remove cycle before trusting it.
  • Private mode changes the semantics. In private browsing, localStorage behaves like sessionStorage: everything is deleted when the browser or tab closes. Also note that access is denied for third-party iframes when the user has disabled third-party cookies.

shared_preferences already wraps this

If you don’t need raw control, shared_preferences (current release 2.5.6) supports web, and its platform table confirms the web backend is LocalStorage. It handles int, double, bool, String, and List<String> with a flutter. key prefix, and since 2.3.0 you should prefer SharedPreferencesAsync or SharedPreferencesWithCache over the legacy SharedPreferences class. The trade-off: you get cross-platform code and an async API, but you inherit the same 5 MiB ceiling and the same “not for critical data” warning from the plugin docs.

When to move beyond localStorage

localStorage is synchronous string storage — fine for settings, poor for anything large. MDN points to IndexedDB as the asynchronous alternative for bigger datasets, with per-origin quotas measured in gigabytes rather than mebibytes on modern browsers. Practical split:

  • Settings, tokens, small JSON → localStorage via package:web, or shared_preferences.
  • Caches, offline documents, media metadata → IndexedDB (via a package such as idb_shim) or the Origin Private File System.
  • Anything you can’t afford to lose → a real backend; browser storage is best-effort and can be evicted under storage pressure.

Takeaway: Use window.localStorage from package:web for small web-only state, wrap every write in a QuotaExceededError guard around the 5 MiB per-origin limit, fall back to shared_preferences for cross-platform key-value pairs, and graduate to IndexedDB once your data stops fitting in a few strings.