Stop shipping multi-megabyte desktop databases to the browser—the Web finally has its own DB.

If you’ve ever built an offline-first or local-first web application, you already know the storage struggle. Everything starts out fine when you're just caching a few JSON objects. Then, real product requirements kick in: you need to join orders with customers, filter by multiple conditions, calculate totals, paginate results, and keep two open browser tabs in sync without corrupting data.

At that point, you have two choices—and both hurt.

Option 1: The IndexedDB Boilerplate Trap

You start with raw IndexedDB (or a thin wrapper around it). Pretty quickly, you realize IndexedDB isn't a relational database. Want to join two tables? You’re writing manual JavaScript loops and merging cursors in memory. Want a compound filter with sorting and pagination? You’re fighting key ranges and writing dozens of lines of imperative code for what should have been a 3-line query.

Option 2: The Heavy Desktop WASM Port

Frustrated with IndexedDB, you reach for SQLite WASM or PGlite. Now you have real relational queries, which feels great—until you look at the trade-offs:

  1. Massive bundles: You're shipping a 1 MB to 5 MB+ WebAssembly binary designed for desktop filesystems just to store data in a browser tab.
  2. Server header headaches: To get fast synchronous file access in a Worker, you need SharedArrayBuffer, which forces you to add strict COOP and COEP isolation headers to your server—instantly breaking third-party scripts, embeds, and auth popups.
  3. Wasted CPU cycles: Your JavaScript code builds a query, serializes it into a SQL text string, crosses the WASM boundary, and hands it to a multi-megabyte C SQL lexer and AST parser just to tokenize that string back into bytecode instructions.

After hitting this wall one too many times while building offline-capable apps, I asked a simple question: What if we built a relational database engine natively for the browser from scratch?

Today, I’m really excited to launch WebDB.

Rethinking the Architecture: Direct-to-Bytecode

Desktop databases need a SQL text parser because queries come over a network socket as raw strings. In the browser, your application code and your database live in the exact same runtime. Why are we serializing objects into strings just to parse them back again?

WebDB eliminates the middleman entirely:

  • Legacy WASM Ports (4 Stages): JS Query / ORM ➔ SQL String Serialization ➔ C SQL Lexer & AST Parser (+1.5 MB bloat) ➔ WASM Execution
  • WebDB (2 Stages): Fluent TypeScript Query Builder ➔ Direct VDBE Binary Bytecode (< 0.05ms)

Because WebDB compiles fluent TypeScript AST directly into compact binary opcodes (OP_INDEX_SCAN, OP_RESULT_ROW, etc.), we don't need to bundle a massive C string parser.

The result? A full relational engine—with B+Tree indexes, joins, aggregates, subqueries, and crash-proof ACID transactions—inside a ~19 KB gzipped WebAssembly core (~51 KB raw), with a pure JS fallback included.

What Makes WebDB Different?

  • ~19 KB Gzipped WASM Core: Around 95% smaller than typical desktop SQLite or Postgres WASM ports.
  • Up to 8.7x Faster than Official SQLite WASM: Skipping SQL string parsing and Emscripten Asyncify stack-unwinding makes in-memory and indexed workloads significantly faster.
  • Zero COOP/COEP Server Headers: Works out of the box on any static host or CDN without breaking your page isolation.
  • Native Async Virtual File System (VFS): Suspends cleanly on 4 KB page faults over OPFS and IndexedDB. You can even query multi-gigabyte read-only databases hosted on S3 or a CDN via HTTP Range: bytes=X-Y requests without downloading the whole file.
  • Crash-Proof ACID & WAL: Write-Ahead Logging with IEEE 802.3 CRC32 checksums on every 4 KB page prevents corruption when users suddenly close a tab. Multi-tab readers and writers coordinate automatically via navigator.locks.
  • 16-Byte Binary UUIDv7 & ULID: Packs UUIDs into 16 raw bytes (58% smaller than 36-char strings) for fast, zero-fragmentation B+Tree inserts.

How to Use WebDB

1. Define a Schema & Run Relational Queries

No SQL strings to escape. You get full TypeScript autocompletion for schemas, composite B+Tree indexes, JOINs, GROUP BY, HAVING, and aggregates:

// 1. Define relational schema with typed binary columns & B+Tree indexes await db.createTable("orders", [ { name: "id", type: "UUID", primaryKey: true }, // Packed into 16 raw bytes { name: "customer_id", type: "UUID", notNull: true }, { name: "status", type: "TEXT", notNull: true }, { name: "total", type: "FLOAT64", notNull: true }, ]); await db.createIndex("orders", ["status", "total"]); // 2. Fluent relational query with JOINs, aggregates & filtering const topCustomers = await db .from("orders") .join("customers", "orders.customer_id", "=", "customers.id") .select("customers.name as customer") .sum("orders.total", "lifetimeValue") .count("*", "orderCount") .where("orders.status", "=", "paid") .groupBy("customers.name") .having("lifetimeValue", ">=", 500) .orderBy("lifetimeValue", "desc") .limit(10) .toArray(); 

If you ever want to see what the cost-based query planner is doing under the hood, just call .explain() on any query to inspect the generated VDBE bytecode instructions directly.

2. Reactive Live Queries (React, Vue, Angular & Plain JS)

One of the most annoying parts of offline apps is wiring manual event buses around database writes so your UI knows when to re-render.

WebDB ships with isolated framework subpaths (under 1 KB gzipped each) that automatically inspect your query AST—including JOINs and subqueries. When a relevant table mutates in the current tab, a Web Worker, or another open browser tab (via BroadcastChannel), your UI updates automatically:

import { useLiveQuery } from "@ahmadmoussawi/webdb/react"; export function OrderDashboard({ status }: { status: string }) { // Automatically tracks 'orders' AND joined 'customers' table! const { data: orders, isLoading } = useLiveQuery( () => db .from("orders") .join("customers", "orders.customer_id", "=", "customers.id") .where("orders.status", "=", status) .orderBy("orders.id", "desc"), [status], { initialData: [] } ); // Mutating 'orders' (here or in another tab) automatically updates the UI! const markPaid = (id: string) => db.from("orders").where("id", "=", id).update({ status: "paid" }); if (isLoading) return <p>Loading live orders...</p>; return <OrderTable rows={orders} onMarkPaid={markPaid} />; } 

Performance Benchmarks (5,000 Rows In-Memory)

Because WebDB compiles TypeScript directly into binary opcodes and avoids C parser overhead, both the ~19 KB WASM engine and even the pure JS fallback beat official SQLite WASM (:memory:) across common relational workloads (tested on a MacBook Pro M1 Max):

Workload Scenario WebDB (WASM) WebDB (Pure JS) SQLite WASM Speedup vs SQLite
Range Filter Scan 1,091.7 ops/s 444.0 ops/s 125.9 ops/s 8.7x faster
Bulk Batch Insert 359,152 ops/s 301,932 ops/s 156,576 ops/s 2.3x faster
Batch Point Lookups 334,448 ops/s 234,192 ops/s 148,148 ops/s 2.3x faster
Indexed 2-Table JOIN 755.3 ops/s 494.6 ops/s 440.1 ops/s 1.7x faster
Filter + Sort + Limit 2,381.0 ops/s 681.7 ops/s 1,483.7 ops/s 1.6x faster

Don't just take my numbers for it—you can run the live benchmark suite on your own machine here.

Try It in Your Browser (Studio IDE)

To make debugging local databases easier, I also built the WebDB Studio Playground.

Right in your browser, you can write queries with full autocompletion, inspect raw binary 4 KB slotted pages in real time, visualize B+Tree structures, and even share live compressed .webdb.gz databases via URL.

Links & Resources

WebDB is completely open-source under the MIT license. If you’ve ever felt the friction of building offline-first or local-first web apps, I’d love for you to try it out, break it, and let me know what you think!

What's been your biggest headache when dealing with client-side storage in the browser? Let me know in the comments!