Production JSON workbenches face two hard constraints: the main thread must stay responsive at 60 fps, and local persistence must survive 10 MB payloads without quota exceptions.
In our foundational guide, we engineered JSON Nexus Visualizer from a Node 22 prototype to a production WordPress plugin. In the first upgrade cycle, we shipped Web Worker parser offloading and a responsive full-viewport flex layout.
This tutorial completes Phase 1 with the final critical upgrade: IndexedDB session storage migration—replacing volatile localStorage with asynchronous, large-capacity storage that handles 10 MB workspaces without quota errors. We’ll walk through the complete storage layer, the async initialization pattern, and the WordPress plugin integration.
Phase 1 Recap: Three Upgrades, One Goal
- Item 1 — Web Worker Parser Offloading: Moved JSON parsing, flattening, profiling, and duplicate-key detection to a dedicated Web Worker. Main thread stays responsive; 15 K rows parse in background with zero UI jank.
- Item 2 — IndexedDB Session Storage Migration: Replaced 5 MB
localStoragequota with IndexedDB (hundreds of MB). Automatic migration, async load/save, graceful fallback. - Item 3 — Responsive Full-Viewport Layout: Flexbox chain from
body→.app-shell→#tablePanel→.table-wrapwithmin-height: 0enabling true viewport fill on all devices.
All three are live at webdevelopmentor.com/json-nexus/ and in every share link (/s/{token}/).
Why IndexedDB? The localStorage Quota Wall
localStorage is synchronous, capped at ~5 MB per origin, and blocks the main thread on every read/write. A 10 MB JSON payload plus UI state (columns, sorts, profiles, schema, monitor history) exceeds this ceiling regularly, throwing QuotaExceededError and silently losing user work.
IndexedDB solves this:
- Asynchronous API — no main-thread blocking on read/write.
- Hundreds of MB quota — browser-managed, scales with available disk.
- Transactional — atomic puts, no partial writes.
- Indexable — ready for future multi-workspace support.
Step 1: The IndexedDB Storage Layer (storage-idb.js)
A self-contained module exposing a tiny global API: JNV_Storage.init(), .load(), .save(), .clear(). No external dependencies.
Database Schema
const IDB_DB_NAME = 'json-nexus-visualizer';
const IDB_STORE_NAME = 'state';
const IDB_VERSION = 1;
// Single key: 'workspace-state' holds the complete merged state object
Initialization with Automatic Migration
function initIndexedDB() {
if (idbInitPromise) return idbInitPromise;
idbInitPromise = new Promise((resolve, reject) => {
if (!window.indexedDB) {
console.warn('[JNV] IndexedDB not supported, falling back to localStorage');
idbAvailable = false;
idbReady = true;
resolve(false);
return;
}
const request = indexedDB.open(IDB_DB_NAME, IDB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
db.createObjectStore(IDB_STORE_NAME);
}
};
request.onsuccess = (event) => {
idbAvailable = true;
idbReady = true;
console.log('[JNV] IndexedDB initialized successfully');
resolve(true);
};
request.onerror = (event) => {
console.warn('[JNV] IndexedDB init failed, falling back to localStorage:', event.target.error);
idbAvailable = false;
idbReady = true;
resolve(false);
};
});
return idbInitPromise;
}
Key design points:
- Promise-based singleton init — safe to call multiple times; returns cached promise.
- Graceful degradation — if
indexedDBabsent or blocked, setsidbAvailable = falseand resolvesfalse; callers fall back tolocalStorage. - No schema migration complexity — single object store, single key; state object is versioned via
DEFAULT_STATEmerge inloadState().
Legacy localStorage Migration
Runs once on first load if IndexedDB is available:
async function migrateLocalStorageToIDB() {
if (!idbAvailable) return false;
try {
const legacyData = localStorage.getItem(STORAGE_KEY); // 'json-nexus:state:v1'
if (!legacyData) return false;
const db = await getIDB();
const tx = db.transaction(IDB_STORE_NAME, 'readwrite');
const store = tx.objectStore(IDB_STORE_NAME);
await new Promise((resolve, reject) => {
const putRequest = store.put(JSON.parse(legacyData), 'workspace-state');
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error);
});
localStorage.setItem(STORAGE_KEY + ':migrated', 'true');
console.log('[JNV] Migrated localStorage to IndexedDB');
return true;
} catch (err) {
console.warn('[JNV] Migration failed:', err);
return false;
}
}
We keep the localStorage copy (with a :migrated flag) for multi-tab sync and as a safety net. The migration is idempotent and non-destructive.
Load / Save / Clear
async function loadStateIDB() {
await waitForIDB();
if (idbAvailable) {
try {
const db = await getIDB();
const tx = db.transaction(IDB_STORE_NAME, 'readonly');
const store = tx.objectStore(IDB_STORE_NAME);
const request = store.get('workspace-state');
const saved = await new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (saved) {
return mergeWithDefaults(saved); // merges with DEFAULT_STATE
}
} catch (err) {
console.warn('[JNV] IndexedDB load failed, trying localStorage:', err);
}
}
// Fallback to localStorage (identical merge logic)
return loadFromLocalStorage();
}
async function persistStateIDB() {
await waitForIDB();
const payload = { ...state, rawInput: els.jsonInput?.value ?? state.rawInput };
// Primary: IndexedDB
if (idbAvailable) {
try {
const db = await getIDB();
const tx = db.transaction(IDB_STORE_NAME, 'readwrite');
const store = tx.objectStore(IDB_STORE_NAME);
await new Promise((resolve, reject) => {
const putRequest = store.put(payload, 'workspace-state');
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error);
});
} catch (err) {
console.warn('[JNV] IndexedDB save failed:', err);
}
}
// Secondary: localStorage (sync for multi-tab, fallback)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
els.saveStatus.textContent = `Auto-saved ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
} catch (err) {
console.warn('[JNV] localStorage save failed:', err);
els.saveStatus.textContent = 'Local storage unavailable';
}
}
async function clearStateIDB() {
await waitForIDB();
if (idbAvailable) { /* delete from IDB */ }
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY + ':migrated');
}
Step 2: Async State Initialization in app.js
The app can no longer synchronously call loadState() at module top level. Instead, we defer state hydration to an async IIFE that runs after DOM ready.
State Declaration (No Sync Load)
// State will be loaded asynchronously in the init function
let state = { ...DEFAULT_STATE };
let storageReady = false;
Async Load / Persist Wrappers
async function loadState() {
if (window.JNV_Storage && window.JNV_Storage.load) {
storageReady = true;
return await window.JNV_Storage.load();
}
// Fallback to localStorage (same merge logic)
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
return mergeWithDefaults(saved || {});
} catch {
return { ...DEFAULT_STATE };
}
}
async function persistState() {
if (storageReady && window.JNV_Storage && window.JNV_Storage.save) {
await window.JNV_Storage.save();
return;
}
// Fallback to localStorage
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...state, rawInput: els.jsonInput?.value ?? state.rawInput }));
els.saveStatus.textContent = `Auto-saved ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
} catch {
els.saveStatus.textContent = 'Local storage unavailable';
}
}
async function resetAll() {
stopMonitoring();
if (storageReady && window.JNV_Storage && window.JNV_Storage.clear) {
await window.JNV_Storage.clear();
} else {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY + ':migrated');
}
Object.assign(state, clone(DEFAULT_STATE));
// ... reset UI, render, toast ...
}
Startup IIFE: Load → Hydrate UI → Init Worker → Render
(async () => {
// 1. Load persisted state FIRST
state = await loadState();
// 2. Hydrate UI inputs from loaded state
Object.assign(els.jsonInput, { value: state.rawInput || '' });
Object.assign(els.endpointInput, { value: state.endpointUrl || '' });
Object.assign(els.filterInput, { value: state.filter || '' });
Object.assign(els.pageSize, { value: String(state.pageSize || 25) });
Object.assign(els.pageJump, { value: String(state.page || 1) });
// 3. Initialize Web Worker
initParserWorker();
// 4. Handle share token or render
const activeToken = getActiveShareToken();
if (activeToken) {
isSharedView = true;
sharedAllowModification = false;
applySharePresentation();
await loadShareFromPath(activeToken);
} else if (state.rawInput) {
await tryParseInput(state.rawInput, { show: true, autoProfile: true });
} else {
render();
}
})();
Order matters: UI must reflect loaded state before the worker starts or the first parse runs, otherwise users see a flash of empty state.
Step 3: WordPress Plugin Integration
Enqueue Storage Script as Dependency
// json-nexus-visualizer.php — jnv_enqueue_assets()
wp_enqueue_script(
'json-nexus-storage',
JSON_NEXUS_URL . 'assets/bbs/js/storage-idb.js',
[],
JSON_NEXUS_VERSION,
['strategy' => 'defer']
);
wp_enqueue_script(
'json-nexus-app',
JSON_NEXUS_URL . 'assets/app.js',
['json-nexus-profiler', 'json-nexus-schema-validator', 'json-nexus-monitor', 'json-nexus-table-export', 'json-nexus-storage'],
JSON_NEXUS_VERSION,
['strategy' => 'defer']
);
The storage script loads before app.js (via dependency array), so window.JNV_Storage exists when the startup IIFE runs.
Standalone Share Template
// visualizer-standalone.php — inline script tags
Share links (/s/{token}/) get the same storage layer, so users who bookmark a shared visualization can reload it and their UI preferences (theme, column order, density) persist locally.
Verification Checklist
- IndexedDB opens: DevTools → Application → IndexedDB →
json-nexus-visualizer→state→ keyworkspace-statepresent. - Migration runs once: Console shows
[JNV] Migrated localStorage to IndexedDBon first visit after upgrade;:migratedflag set in localStorage. - 10 MB payload persists: Drop a 4–8 MB JSON file; refresh page; workspace restores with theme, column order, filter, sort intact.
- Multi-tab sync: Open two tabs; change theme in one; refresh other → theme updates (via localStorage sync).
- Fallback works: Disable IndexedDB via Chrome DevTools → Application → Clear storage → uncheck IndexedDB; reload; workspace still loads from localStorage.
- Share link persistence: Open
/s/{token}/in incognito; change density; close/reopen → density preserved. - Reset clears both: Click Reset button; IndexedDB key deleted; localStorage key +
:migratedflag removed.
Complete Phase 1 Architecture
| Layer | File | Purpose |
|---|---|---|
| Storage | assets/bbs/js/storage-idb.js | IndexedDB + localStorage fallback, migration, async API |
| Worker | assets/bbs/js/parser-worker.js | Off-thread parse, flatten, profile, duplicate-key stats |
| App Core | assets/app.js | Async state init, worker delegation, UI hydration, render loop |
| Profiling | assets/bbs/js/profiler.js | Archetype detection, schema hints, layout strategy |
| Validation | assets/bbs/js/schema-validator.js | Draft-07 JSON Schema validator |
| Monitoring | assets/bbs/js/monitor.js | REST polling, diffing, change highlighting |
| Export | assets/bbs/js/table-export.js | CSV, Markdown, HTML, SVG, PNG, PDF |
| Styles | assets/styles.css | Flexbox layout, themes, responsive breakpoints |
| WP Plugin | json-nexus-visualizer.php | Enqueue, localize, REST endpoints, shortcode, block |
| Templates | templates/visualizer-embed.php, visualizer-standalone.php | Shortcode/block embed + standalone share view |
Try the Complete Phase 1 Tool
All three upgrades are live. Drop a 10 MB JSON file, watch the worker parse it without freezing the UI, resize to mobile viewport, refresh — your workspace is exactly as you left it.
The complete plugin (GPLv3) includes the PHP PDO SQLite backend, 90-day cryptographic share links, REST monitoring, JSON Schema validation, and all Phase 1 client-side upgrades. Download the plugin ZIP from the tool page or clone the repository for self-hosted deployment.