Persist slider values, toggles, camera angles and particle counts across
page reloads with zero backend — using localStorage,
JSON.stringify, schema versioning, and IndexedDB for larger
data.
1
localStorage Read/Write Basics
localStorage is a synchronous key-value store with up to
~5–10 MB per origin (browser-dependent). Values must be strings, so
use JSON for structured data.
// Write — always stringify objects localStorage.setItem('gravity',
'9.81'); localStorage.setItem('simState', JSON.stringify({ particles:
500, gravity: 9.81, paused: false })); // Read — parse back to the
original type const g = parseFloat(localStorage.getItem('gravity') ??
'9.81'); const st = JSON.parse(localStorage.getItem('simState') ??
'null'); // Delete one key localStorage.removeItem('gravity'); //
Delete ALL keys for this origin — be careful! // localStorage.clear();
// Iterate all stored keys for (let i = 0; i < localStorage.length;
i++) { const key = localStorage.key(i); console.log(key,
localStorage.getItem(key)); }
localStorage is synchronous and blocks the main thread.
For reads and small writes (< 1 KB) this is fine. Never store binary
data or large buffers — use IndexedDB instead (Step 6).
2
Save All Simulation Parameters at Once
Rather than storing individual keys, collect the entire simulation
state into one snapshot object and write it under a single key. This
makes saves and loads atomic.
// Simulation state — all parameters in one object const DEFAULT_STATE
= { particleCount: 500, gravity: -9.81, friction: 0.98, paused: false,
colorMode: 'velocity', // 'velocity' | 'density' | 'uniform' camera: {
x: 0, y: 5, z: 15, pitch: 0, yaw: 0 }, }; let state = {
...DEFAULT_STATE }; // --- Save --- function saveState() { try {
localStorage.setItem('boids-state', JSON.stringify(state)); } catch
(e) { handleQuotaError(e); // Step 5 } } // Call saveState() whenever
the user changes a parameter:
document.getElementById('gravity-slider').addEventListener('input', e
=> { state.gravity = parseFloat(e.target.value); saveState(); }); //
Or save on window beforeunload (catches tab close)
window.addEventListener('beforeunload', saveState);
3
Auto-Restore State on Page Load
On startup, load the saved state and apply it to every UI control
before initialising the simulation. Fall back to defaults if nothing
is stored.
function loadState() { const raw =
localStorage.getItem('boids-state'); if (!raw) return DEFAULT_STATE;
try { const saved = JSON.parse(raw); // Merge with defaults so new
keys added in later versions still exist return Object.assign({},
DEFAULT_STATE, saved); } catch { console.warn('Corrupt state,
resetting'); localStorage.removeItem('boids-state'); return
DEFAULT_STATE; } } // Apply state to DOM controls function
applyStateToUI(st) { document.getElementById('particle-count').value =
st.particleCount; document.getElementById('gravity-slider').value =
st.gravity; document.getElementById('friction-slider').value =
st.friction; document.getElementById('paused-toggle').checked =
st.paused; document.getElementById('color-mode').value = st.colorMode;
} // --- Boot sequence --- state = loadState(); applyStateToUI(state);
initSimulation(state); // pass saved state to your sim init
Always merge with defaults (Object.assign({}, DEFAULT_STATE, saved)). If a user upgrades the page and a new field is added to defaults,
the merge ensures they get the new default value instead of
undefined.
4
Schema Versioning and Migration
When you change the structure of your state object (rename a key,
change a unit), old saved data becomes incompatible. Add a
_v version number and migrate old formats.
const SCHEMA_VERSION = 3; // bump this whenever the state schema
changes function loadStateVersioned() { const raw =
localStorage.getItem('boids-state'); if (!raw) return {
...DEFAULT_STATE, _v: SCHEMA_VERSION }; let saved; try { saved =
JSON.parse(raw); } catch { return { ...DEFAULT_STATE, _v:
SCHEMA_VERSION }; } // Migrate from older versions const v = saved._v
?? 1; if (v < 2) { // v1→v2: renamed 'speed' to 'gravity', changed
sign convention saved.gravity = -(saved.speed ?? 9.81); delete
saved.speed; } if (v < 3) { // v2→v3: camera moved from flat keys to
nested object saved.camera = { x: saved.camX ?? 0, y: saved.camY ?? 5,
z: saved.camZ ?? 15, pitch: 0, yaw: 0 }; delete saved.camX; delete
saved.camY; delete saved.camZ; } saved._v = SCHEMA_VERSION; return
Object.assign({}, DEFAULT_STATE, saved); }
Keep migrations additive — only transform old fields, never silently
delete user data. If a migration is destructive (e.g., units changed
from kg to g), show a one-time banner: "Settings were updated to match
a new format."
5
Quota Errors and Graceful Fallback
Safari caps localStorage at 5 MB, Firefox/Chrome at 10 MB. When
storage is full, setItem throws a
QuotaExceededError. Always wrap writes in try/catch.
function handleQuotaError(error) { // QuotaExceededError codes vary by
browser const isQuota = error.name === 'QuotaExceededError' ||
error.name === 'NS_ERROR_DOM_QUOTA_REACHED' || error.code === 22 ||
error.code === 1014; if (isQuota) { // Strategy 1: prune old/large
keys const KEEP = ['boids-state', 'user-prefs']; for (let i =
localStorage.length - 1; i >= 0; i--) { const key =
localStorage.key(i); if (!KEEP.includes(key))
localStorage.removeItem(key); } // Retry once try {
localStorage.setItem('boids-state', JSON.stringify(state)); } catch {
/* give up */ } return; } // Unavailable (private browsing, storage
disabled) console.warn('localStorage unavailable:', error.message); }
// Test if localStorage is available at startup function
storageAvailable() { try { localStorage.setItem('__test__', '1');
localStorage.removeItem('__test__'); return true; } catch { return
false; } } const CAN_PERSIST = storageAvailable();
In Safari's private mode localStorage exists but throws
immediately on any write (quota = 0). Always run the availability
check at startup and show a "settings not saved" indicator if it
fails.
6
IndexedDB for Large Data
For data larger than a few KB — spatial hash tables, pre-computed
lookup textures, large particle snapshots — use IndexedDB. It is
asynchronous, supports binary data, and has no practical size limit
above a few hundred MB.
// Minimal IndexedDB helper (async/await) function openDB(name,
version, upgrade) { return new Promise((resolve, reject) => { const
req = indexedDB.open(name, version); req.onupgradeneeded = e =>
upgrade(e.target.result); req.onsuccess = e =>
resolve(e.target.result); req.onerror = e => reject(e.target.error);
}); } const txGet = (db, store, key) => new Promise((res, rej) => {
const tx = db.transaction(store, 'readonly'); const req =
tx.objectStore(store).get(key); req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error); }); const txPut = (db, store, key,
value) => new Promise((res, rej) => { const tx = db.transaction(store,
'readwrite'); const req = tx.objectStore(store).put(value, key);
req.onsuccess = () => res(); req.onerror = () => rej(req.error); });
// --- Usage --- const db = await openDB('sim-cache', 1, db => {
db.createObjectStore('blobs'); // stores any value by string key });
// Save a large Float32Array (density field snapshot) const snapshot =
new Float32Array(512 * 512); // ... fill snapshot ... await txPut(db,
'blobs', 'density-field', snapshot); // Load it back const restored =
await txGet(db, 'blobs', 'density-field'); // restored is the same
Float32Array
When to use which: localStorage → small JSON state (< 10 KB),
synchronous access needed, simple key/value. sessionStorage → same as localStorage but cleared
when the tab closes — good for temporary state. IndexedDB → typed arrays, blobs, large lookup tables,
texture cache, anything over ~50 KB.