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.localStoragebut 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,
localStoragebehaves likesessionStorage: 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 →
localStorageviapackage:web, orshared_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.