Skip to content

Instantly share code, notes, and snippets.

@krgeppert
Created March 16, 2026 07:48
Show Gist options
  • Select an option

  • Save krgeppert/d157b4d1440b55a0b7b990a95250a3f1 to your computer and use it in GitHub Desktop.

Select an option

Save krgeppert/d157b4d1440b55a0b7b990a95250a3f1 to your computer and use it in GitHub Desktop.
<!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