JavaScript · Web APIs
⏱ ~35 min🟢 BeginnerVanilla JS · No dependencies

Save Simulation Settings with localStorage + JSON

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.