DuckDB and OPFS for Browser Storage

DuckDBOPFSWebAssemblyFunctional ProgrammingWeb StorageTypeScript

Persisting Large Databases in the Client

I’ve been playing around with DuckDB WebAssembly lately, and I wanted to see if I could build something that persists data in the browser without any server. Not just localStorage stuff, but a real database with SQL queries and transactions.

So I built a todo list demo using DuckDB and OPFS (Origin Private File System). It’s a fully functional app that stores everything locally, survives browser restarts, and uses pure functional programming throughout.

Why this combo?

DuckDB gives you proper SQL in the browser. Full queries, joins, transactions - the works. It’s WebAssembly, so it’s fast and runs everywhere.

OPFS is the new browser storage API that actually persists data properly. Unlike localStorage, it can handle large files and gives you direct file system access. Your data survives browser restarts and clearing cache.

Together, they let you build desktop-class applications that run in the browser.

How I built it

I went with pure functional programming for this. No classes, no mutable state. Everything’s a function that takes some input and returns new data:

interface TodoState {
  readonly todos: readonly Todo[];
  readonly filter: TodoFilter;
  readonly nextId: number;
}

interface Todo {
  readonly id: number;
  readonly text: string;
  readonly completed: boolean;
  readonly createdAt: Date;
}

Each operation creates new state instead of changing existing objects. Makes debugging much easier.

The database schema is simple:

CREATE TABLE IF NOT EXISTS todos (
  id INTEGER PRIMARY KEY,
  text VARCHAR NOT NULL,
  completed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)

I wrapped all the database stuff in functions:

async function addTodo(db: Database, text: string): Promise<void> {
  await db.exec(`
    INSERT INTO todos (text, completed, created_at) 
    VALUES (?, FALSE, CURRENT_TIMESTAMP)
  `, [text]);
}

async function toggleTodo(db: Database, id: number): Promise<void> {
  await db.exec(`
    UPDATE todos 
    SET completed = NOT completed 
    WHERE id = ?
  `, [id]);
}

The OPFS part is where it gets interesting. When your browser supports it, DuckDB writes directly to the private file system:

async function initializeDatabase(): Promise<Database> {
  const bundle = await DuckDBBundle();
  
  if (bundle.logger) {
    bundle.logger.useConsole();
  }

  const worker = new Worker(bundle.mainWorker!);
  const logger = new ConsoleLogger();
  const db = new AsyncDuckDB(logger, worker);
  
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

  // OPFS connection when available
  const conn = await db.connect();
  
  return { db, conn };
}

I added a debug console that shows what’s actually happening:

  • Whether you’re using OPFS or just memory
  • How big your database file is
  • Live SQL queries as they run
  • Performance timings

It’s helpful to see what’s going on behind the scenes.

What it does

This isn’t your typical localStorage todo app. You get real database persistence with ACID transactions. Data survives everything - browser restarts, cache clearing, the lot.

The functional programming makes everything predictable. No mysterious state changes, no hidden mutations. Every function does one thing and returns new data.

The UI updates reactively without any framework:

function renderTodos(state: TodoState): void {
  const container = document.getElementById('todo-list');
  if (!container) return;
  
  const filteredTodos = getFilteredTodos(state);
  container.innerHTML = filteredTodos
    .map(todo => renderTodoItem(todo))
    .join('');
}

Performance

It falls back gracefully. If your browser supports OPFS, you get persistence. If not, it runs in memory and you lose data on refresh. No configuration needed.

The WebAssembly overhead is pretty minimal:

  • About 2MB initial download
  • Queries run at near-native speed
  • Memory usage is efficient

Browser support

Chrome and Edge get the full experience with OPFS. Firefox and Safari fall back to memory-only for now. Mobile browsers are hit-and-miss depending on which one you’re using.

What I learned

WebAssembly is ready for production. DuckDB performs really well for client-side database work.

OPFS is genuinely useful when it works, but browser support is still patchy. You need fallbacks.

Functional programming made the whole thing easier to build and debug. No surprises, no hidden state.

The main challenge is the bundle size. 2MB is a lot for some use cases, though it caches well after the first load.

When to use this

It’s great for:

  • Offline-first apps
  • Data analysis tools
  • Privacy-focused apps where data never leaves the device
  • Prototypes that don’t need a backend

Probably not ideal for:

  • Apps where bundle size matters a lot
  • Anything that needs IE support
  • Real-time collaboration
  • Huge datasets that won’t fit in browser memory

What’s next

I think this is where web apps are heading. Local-first software that works offline by default. Your data stays on your device, but you still get the power of SQL and proper databases.

It’s pretty exciting stuff.

The live demo is worth playing with. Add some todos, refresh the page, watch the debug console. You’ll see OPFS doing its thing.

All the source is on GitHub if you want to dig deeper.

Wrapping up

Building this showed me how powerful browsers are becoming. You can run a real database client-side now, with proper persistence. That opens up a lot of possibilities.

The functional approach made everything cleaner and easier to debug. As these browser apps get more complex, having predictable code becomes really important.

We’re heading toward a world where web apps and desktop apps are basically the same thing. OPFS and WebAssembly are big steps in that direction.

🚀 Explore the Demo

Ready to see DuckDB and OPFS in action?

🦆 Try the Live Demo

Interactive todo list with DuckDB and OPFS persistence

View Source Code

Complete implementation with TypeScript and documentation