Created
March 16, 2026 07:48
-
-
Save krgeppert/d157b4d1440b55a0b7b990a95250a3f1 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>IndexedDB vs OPFS Benchmark</title> | |
| <style> | |
| body { font-family: monospace; background: #111; color: #eee; margin: 20px; } | |
| h1 { color: #7cf; } | |
| #status { color: #aaa; margin-bottom: 10px; } | |
| .bench { margin-bottom: 24px; } | |
| .bench-label { color: #fa8; margin-bottom: 4px; font-size: 13px; } | |
| .bench-stats { color: #cfc; margin-bottom: 2px; font-size: 12px; min-height: 1em; } | |
| .bench-stats2 { color: #adf; margin-bottom: 6px; font-size: 12px; min-height: 1em; } | |
| canvas { background: #1a1a2e; border: 1px solid #333; display: block; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Hello World — IndexedDB vs OPFS Benchmark</h1> | |
| <div id="status">Initializing...</div> | |
| <div class="bench"> | |
| <div class="bench-label">WRITES — IDB (amber) vs OPFS (green)</div> | |
| <div class="bench-stats" id="idb-write-stats"></div> | |
| <div class="bench-stats2" id="opfs-write-stats"></div> | |
| <canvas id="write-chart" width="900" height="260"></canvas> | |
| </div> | |
| <div class="bench"> | |
| <div class="bench-label">READS — IDB (blue) vs OPFS (pink)</div> | |
| <div class="bench-stats" id="idb-read-stats"></div> | |
| <div class="bench-stats2" id="opfs-read-stats"></div> | |
| <canvas id="read-chart" width="900" height="260"></canvas> | |
| </div> | |
| <script> | |
| const DB_NAME = 'bench_db'; | |
| const STORE_NAME = 'items'; | |
| const INDEX_NAME = 'by_category'; | |
| const CATEGORIES = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; | |
| const statusEl = document.getElementById('status'); | |
| // ~1MB zero-filled payload; size matters, not content. | |
| const CHUNK_BYTES = 8 * 1_048_576; // 8MB | |
| const PAYLOAD = 'x'.repeat(CHUNK_BYTES); | |
| // ── IndexedDB ──────────────────────────────────────────────────────────── | |
| function deleteDB() { | |
| return new Promise(resolve => { | |
| const req = indexedDB.deleteDatabase(DB_NAME); | |
| req.onsuccess = resolve; req.onerror = resolve; | |
| }); | |
| } | |
| function openDB() { | |
| return new Promise((resolve, reject) => { | |
| const req = indexedDB.open(DB_NAME, 1); | |
| req.onupgradeneeded = e => { | |
| const db = e.target.result; | |
| if (!db.objectStoreNames.contains(STORE_NAME)) { | |
| const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); | |
| store.createIndex(INDEX_NAME, 'category', { unique: false }); | |
| } | |
| }; | |
| req.onsuccess = e => resolve(e.target.result); | |
| req.onerror = e => reject(e.target.error); | |
| }); | |
| } | |
| function seedDB(db) { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_NAME, 'readwrite'); | |
| for (let i = 0; i < 100; i++) | |
| tx.objectStore(STORE_NAME).put({ id: i, category: CATEGORIES[i % CATEGORIES.length], payload: PAYLOAD, ts: Date.now() }); | |
| tx.oncomplete = resolve; | |
| tx.onerror = e => reject(e.target.error); | |
| }); | |
| } | |
| function idbRead(db) { | |
| return new Promise((resolve, reject) => { | |
| const t0 = performance.now(); | |
| const tx = db.transaction(STORE_NAME, 'readonly'); | |
| tx.onerror = tx.onabort = e => reject(e.target.error); | |
| const req = tx.objectStore(STORE_NAME).index(INDEX_NAME).openCursor(); | |
| req.onsuccess = e => { | |
| const c = e.target.result; | |
| if (c) { c.continue(); } else { resolve(performance.now() - t0); } | |
| }; | |
| req.onerror = e => reject(e.target.error); | |
| }); | |
| } | |
| function makePayload(i) { | |
| // vary content each call without crypto — single repeated char that changes per write | |
| return String.fromCharCode(65 + (i % 26)).repeat(CHUNK_BYTES); | |
| } | |
| function idbWrite(db, i, payload) { | |
| return new Promise((resolve, reject) => { | |
| const t0 = performance.now(); | |
| const tx = db.transaction(STORE_NAME, 'readwrite'); | |
| tx.onerror = tx.onabort = e => reject(e.target.error); | |
| tx.oncomplete = () => resolve(performance.now() - t0); | |
| tx.objectStore(STORE_NAME).put({ | |
| id: i % 1000, category: CATEGORIES[i % CATEGORIES.length], | |
| payload, ts: Date.now(), | |
| }); | |
| }); | |
| } | |
| // ── OPFS worker ────────────────────────────────────────────────────────── | |
| const WORKER_SRC = ` | |
| let handle = null; | |
| let offset = 0; | |
| self.onmessage = async (e) => { | |
| const { type } = e.data; | |
| if (type === 'init') { | |
| try { | |
| const root = await navigator.storage.getDirectory(); | |
| try { await root.removeEntry('opfs_bench', { recursive: true }); } catch (_) {} | |
| const dir = await root.getDirectoryHandle('opfs_bench', { create: true }); | |
| const fh = await dir.getFileHandle('data.bin', { create: true }); | |
| try { | |
| handle = await fh.createSyncAccessHandle({ mode: 'readwrite-unsafe' }); | |
| } catch (_) { | |
| handle = await fh.createSyncAccessHandle(); | |
| } | |
| handle.truncate(0); | |
| offset = 0; | |
| self.postMessage({ type: 'ready' }); | |
| } catch (err) { | |
| self.postMessage({ type: 'error', message: err.message }); | |
| } | |
| } else if (type === 'write') { | |
| handle.write(e.data.buffer, { at: offset }); // sync append | |
| handle.flush(); // sync flush to disk | |
| offset += e.data.buffer.byteLength; | |
| self.postMessage({ type: 'write-done', id: e.data.id }); | |
| } else if (type === 'read') { | |
| // read a fixed 1MB window so timing stays comparable regardless of file size | |
| const size = handle.getSize(); | |
| if (size > 0) handle.read(new Uint8Array(Math.min(size, 8 * 1_048_576)), { at: 0 }); | |
| self.postMessage({ type: 'read-done', id: e.data.id }); | |
| } | |
| }; | |
| `; | |
| function makeWorker() { | |
| return new Worker(URL.createObjectURL(new Blob([WORKER_SRC], { type: 'application/javascript' }))); | |
| } | |
| function initOpfsWorker(worker) { | |
| return new Promise((resolve, reject) => { | |
| worker.onmessage = e => { | |
| if (e.data.type === 'ready') resolve(); | |
| else if (e.data.type === 'error') reject(new Error(e.data.message)); | |
| }; | |
| worker.postMessage({ type: 'init' }); | |
| }); | |
| } | |
| let _opfsMsgId = 0; | |
| function opfsSend(worker, msg, transfer) { | |
| const id = ++_opfsMsgId; | |
| return new Promise(resolve => { | |
| function handler(e) { | |
| if (e.data.id === id) { | |
| worker.removeEventListener('message', handler); | |
| resolve(performance.now() - t0); | |
| } | |
| } | |
| worker.addEventListener('message', handler); | |
| const t0 = performance.now(); | |
| worker.postMessage({ ...msg, id }, transfer || []); | |
| }); | |
| } | |
| function opfsWrite(worker, i) { | |
| const buf = new Uint8Array(CHUNK_BYTES).fill(i % 256); | |
| return opfsSend(worker, { type: 'write', buffer: buf }, [buf.buffer]); | |
| } | |
| function opfsRead(worker) { | |
| return opfsSend(worker, { type: 'read' }); | |
| } | |
| // ── Charting ───────────────────────────────────────────────────────────── | |
| function rollingAvg(arr, w = 20) { | |
| if (!arr.length) return 0; | |
| const s = arr.slice(-w); | |
| return s.reduce((a, b) => a + b, 0) / s.length; | |
| } | |
| // Draw two series (write, read) on the same canvas with a shared Y axis. | |
| function drawChart(canvas, wRaw, wAvg, wColor, rRaw, rAvg, rColor) { | |
| const ctx = canvas.getContext('2d'); | |
| const W = canvas.width, H = canvas.height; | |
| const PAD = { top: 24, right: 20, bottom: 30, left: 65 }; | |
| const plotW = W - PAD.left - PAD.right; | |
| const plotH = H - PAD.top - PAD.bottom; | |
| ctx.clearRect(0, 0, W, H); | |
| const n = Math.max(wAvg.length, rAvg.length); | |
| if (n < 2) return; | |
| const maxVal = Math.max(...wAvg, ...rAvg, 0.1) * 1.2; | |
| const toX = (i, len) => PAD.left + (i / (len - 1)) * plotW; | |
| const toY = v => PAD.top + plotH - Math.min(v / maxVal, 1) * plotH; | |
| // grid | |
| for (let i = 0; i <= 5; i++) { | |
| const y = PAD.top + plotH - (i / 5) * plotH; | |
| ctx.strokeStyle = '#2a2a4a'; ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + plotW, y); ctx.stroke(); | |
| ctx.fillStyle = '#666'; ctx.font = '11px monospace'; ctx.textAlign = 'right'; | |
| ctx.fillText(((i / 5) * maxVal).toFixed(1) + 'ms', PAD.left - 4, y + 4); | |
| } | |
| // axes | |
| ctx.strokeStyle = '#555'; ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(PAD.left, PAD.top); ctx.lineTo(PAD.left, PAD.top + plotH); | |
| ctx.lineTo(PAD.left + plotW, PAD.top + plotH); ctx.stroke(); | |
| function drawSeries(raw, avg, color) { | |
| if (avg.length < 2) return; | |
| const len = avg.length; | |
| // raw faint | |
| ctx.strokeStyle = color + '35'; ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| raw.slice(-len).forEach((v, i) => i === 0 ? ctx.moveTo(toX(i, len), toY(v)) : ctx.lineTo(toX(i, len), toY(v))); | |
| ctx.stroke(); | |
| // rolling avg | |
| ctx.strokeStyle = color; ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| avg.forEach((v, i) => i === 0 ? ctx.moveTo(toX(i, len), toY(v)) : ctx.lineTo(toX(i, len), toY(v))); | |
| ctx.stroke(); | |
| } | |
| drawSeries(wRaw, wAvg, wColor); | |
| drawSeries(rRaw, rAvg, rColor); | |
| // legend | |
| ctx.font = '11px monospace'; ctx.textAlign = 'left'; | |
| ctx.fillStyle = wColor; ctx.fillText('IDB avg', PAD.left + 8, PAD.top + 14); | |
| ctx.fillStyle = rColor; ctx.fillText('OPFS avg', PAD.left + 100, PAD.top + 14); | |
| } | |
| // ── Single serialized loop ─────────────────────────────────────────────── | |
| // All 4 ops run in series each iteration; graphs update once per round-trip. | |
| function fmtTotal(ms) { | |
| return ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : ms.toFixed(0) + 'ms'; | |
| } | |
| const idbWRaw = [], idbWAvg = [], idbRRaw = [], idbRAvg = []; | |
| const opfsWRaw = [], opfsWAvg = [], opfsRRaw = [], opfsRAvg = []; | |
| let idbWCount = 0, idbRCount = 0, idbTotalWriteMs = 0, idbTotalReadMs = 0, idbBytes = 0; | |
| let opfsWCount = 0, opfsRCount = 0, opfsTotalWriteMs = 0, opfsTotalReadMs = 0, opfsBytes = 0; | |
| const idbWStats = document.getElementById('idb-write-stats'); | |
| const idbRStats = document.getElementById('idb-read-stats'); | |
| const opfsWStats = document.getElementById('opfs-write-stats'); | |
| const opfsRStats = document.getElementById('opfs-read-stats'); | |
| const writeCanvas = document.getElementById('write-chart'); | |
| const readCanvas = document.getElementById('read-chart'); | |
| const t0 = performance.now(); | |
| async function runLoop(db, worker) { | |
| while (true) { | |
| // 1. IDB write — payload generated here, before t0 inside idbWrite | |
| const iw = await idbWrite(db, idbWCount, makePayload(idbWCount)); | |
| idbWCount++; idbBytes += CHUNK_BYTES; idbTotalWriteMs += iw; | |
| idbWRaw.push(iw); idbWAvg.push(rollingAvg(idbWRaw)); | |
| // 2. IDB read | |
| const ir = await idbRead(db); | |
| idbRCount++; idbTotalReadMs += ir; | |
| idbRRaw.push(ir); idbRAvg.push(rollingAvg(idbRRaw)); | |
| // 3. OPFS write — buf generated here, before t0 inside opfsSend | |
| const ow = await opfsWrite(worker, opfsWCount); | |
| opfsWCount++; opfsBytes += CHUNK_BYTES; opfsTotalWriteMs += ow; | |
| opfsWRaw.push(ow); opfsWAvg.push(rollingAvg(opfsWRaw)); | |
| // 4. OPFS read | |
| const or_ = await opfsRead(worker); | |
| opfsRCount++; opfsTotalReadMs += or_; | |
| opfsRRaw.push(or_); opfsRAvg.push(rollingAvg(opfsRRaw)); | |
| // update stats + redraw | |
| const elapsed = (performance.now() - t0) / 1000; | |
| idbWStats.textContent = `IDB cnt: ${idbWCount} | ${(idbBytes/1024**3).toFixed(3)} GB | ${(idbBytes/1024**2/elapsed).toFixed(1)} MB/s | last: ${iw.toFixed(2)}ms | avg: ${idbWAvg.at(-1).toFixed(2)}ms | total: ${fmtTotal(idbTotalWriteMs)}`; | |
| opfsWStats.textContent = `OPFS cnt: ${opfsWCount} | ${(opfsBytes/1024**3).toFixed(3)} GB | ${(opfsBytes/1024**2/elapsed).toFixed(1)} MB/s | last: ${ow.toFixed(2)}ms | avg: ${opfsWAvg.at(-1).toFixed(2)}ms | total: ${fmtTotal(opfsTotalWriteMs)}`; | |
| idbRStats.textContent = `IDB cnt: ${idbRCount} | last: ${ir.toFixed(2)}ms | avg: ${idbRAvg.at(-1).toFixed(2)}ms | total: ${fmtTotal(idbTotalReadMs)}`; | |
| opfsRStats.textContent = `OPFS cnt: ${opfsRCount} | last: ${or_.toFixed(2)}ms | avg: ${opfsRAvg.at(-1).toFixed(2)}ms | total: ${fmtTotal(opfsTotalReadMs)}`; | |
| drawChart(writeCanvas, idbWRaw, idbWAvg, '#fab050', opfsWRaw, opfsWAvg, '#a0f0a0'); | |
| drawChart(readCanvas, idbRRaw, idbRAvg, '#64c8ff', opfsRRaw, opfsRAvg, '#e080f0'); | |
| await new Promise(r => requestAnimationFrame(r)); | |
| } | |
| } | |
| // ── Main ───────────────────────────────────────────────────────────────── | |
| async function main() { | |
| statusEl.textContent = 'Clearing old IDB...'; | |
| await deleteDB(); | |
| statusEl.textContent = 'Opening IDB...'; | |
| const db = await openDB(); | |
| statusEl.textContent = 'Seeding IDB...'; | |
| await seedDB(db); | |
| statusEl.textContent = 'Initializing OPFS worker...'; | |
| const worker = makeWorker(); | |
| await initOpfsWorker(worker); | |
| statusEl.textContent = 'Running'; | |
| runLoop(db, worker); | |
| } | |
| main().catch(err => { | |
| statusEl.textContent = 'Error: ' + err.message; | |
| console.error(err); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment