Vintage library with holographic database table and a gray cat resting on a book

sql.js: The Entire SQLite Engine Running in Your Browser Tab

The Gray Cat
The Gray Cat

There is something deeply satisfying about running SELECT * FROM users WHERE age > 30 and getting instant results, no server round-trip, no database daemon, no Docker container spinning up in the background. Just pure SQL, executed entirely within your browser tab. That is exactly what sql.js delivers.

sql.js is the full SQLite database engine compiled to WebAssembly using Emscripten. Not a subset of SQL. Not a toy query parser. The real, complete SQLite -- joins, subqueries, common table expressions, window functions, triggers, views, JSON functions, full-text search. All 150,000 lines of the SQLite C amalgamation, faithfully transpiled to run wherever JavaScript runs. It has been doing this since 2012, making it the pioneer of client-side SQL, and it shows no signs of slowing down with over 366,000 weekly npm downloads.

What Makes It Tick

sql.js is deceptively simple in concept but rich in capability:

  • Complete SQLite dialect including CTEs, window functions, triggers, views, JSON functions, and FTS
  • Zero runtime dependencies -- the WASM binary is entirely self-contained
  • Import and export real .sqlite files as Uint8Array for full interoperability with native SQLite tools
  • Custom SQL functions -- register your own JavaScript functions and aggregates callable from SQL
  • Web Worker support via built-in worker scripts that keep heavy queries off the main thread
  • BigInt support for precise handling of large integers
  • Cross-platform -- browser, Node.js, Electron, Deno, and Cloudflare Workers
  • Multiple build variants including WASM, asm.js fallback, and a new browser-only build in v1.14.0

Setting Up Your In-Browser Database

Install the package and its TypeScript types:

npm install sql.js
npm install --save-dev @types/sql.js

or

yarn add sql.js
yarn add -D @types/sql.js

The package ships multiple build variants. The WASM version (sql-wasm.js + sql-wasm.wasm) is the recommended default. As of v1.14.0, there is also a sql-wasm-browser.js variant that eliminates those pesky Node.js require() warnings in bundlers like Vite and Webpack. The package.json exports field auto-selects the right build for your environment.

Querying From Scratch

Creating a Database and Running SQL

Everything starts with initializing the WASM binary, which is an async operation. Once that resolves, you get a Database constructor and the full power of SQL at your fingertips:

import initSqlJs, { Database } from "sql.js";

async function createDatabase(): Promise<Database> {
  const SQL = await initSqlJs({
    locateFile: (file: string) => `https://sql.js.org/dist/${file}`,
  });

  const db = new SQL.Database();

  db.run(`
    CREATE TABLE products (
      id INTEGER PRIMARY KEY,
      name TEXT NOT NULL,
      price REAL,
      category TEXT
    );
    INSERT INTO products VALUES (1, 'Mechanical Keyboard', 149.99, 'peripherals');
    INSERT INTO products VALUES (2, 'Ultrawide Monitor', 599.00, 'displays');
    INSERT INTO products VALUES (3, 'Standing Desk', 449.50, 'furniture');
    INSERT INTO products VALUES (4, 'USB-C Hub', 39.99, 'peripherals');
  `);

  return db;
}

The locateFile callback tells sql.js where to fetch the WASM binary from. In Node.js you can omit this entirely and it finds the file automatically. In the browser, point it at a CDN or your own static assets.

Reading Data Back

The exec method returns results as an array of objects, each containing column names and row values:

const results = db.exec("SELECT name, price FROM products WHERE category = ?", [
  "peripherals",
]);

// results = [{
//   columns: ['name', 'price'],
//   values: [
//     ['Mechanical Keyboard', 149.99],
//     ['USB-C Hub', 39.99]
//   ]
// }]

For row-by-row iteration, prepared statements give you finer control and better performance for repeated queries:

const stmt = db.prepare("SELECT * FROM products WHERE price > $threshold");
stmt.bind({ $threshold: 100 });

while (stmt.step()) {
  const row = stmt.getAsObject();
  console.log(`${row.name}: $${row.price}`);
}

stmt.free(); // Important: WASM memory is not garbage collected

That stmt.free() call is not optional. The WASM heap lives outside the JavaScript garbage collector, so forgetting to free statements and close databases will leak memory. Treat it like C, because under the hood, it literally is.

Loading an Existing Database

One of the most powerful features is the ability to load a real .sqlite file and query it instantly:

async function loadRemoteDatabase(): Promise<Database> {
  const SQL = await initSqlJs({
    locateFile: (file: string) => `https://sql.js.org/dist/${file}`,
  });

  const response = await fetch("/data/analytics.sqlite");
  const buffer = await response.arrayBuffer();
  const db = new SQL.Database(new Uint8Array(buffer));

  const stats = db.exec(`
    SELECT
      category,
      COUNT(*) as count,
      AVG(price) as avg_price
    FROM products
    GROUP BY category
    ORDER BY avg_price DESC
  `);

  return db;
}

This pattern is incredibly useful for static site databases. Pre-build a SQLite file during your CI pipeline, serve it from a CDN, and let the client query it locally with zero backend infrastructure.

Going Deeper

Teaching SQL New Tricks with Custom Functions

You can register JavaScript functions and call them directly from SQL queries. This bridges the gap between SQL's declarative power and JavaScript's flexibility:

db.create_function("slugify", (text: string) => {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
});

db.exec("SELECT name, slugify(name) as slug FROM products");
// [['Mechanical Keyboard', 'mechanical-keyboard'],
//  ['Ultrawide Monitor', 'ultrawide-monitor'], ...]

db.create_aggregate("json_agg", {
  init: () => [],
  step: (state: unknown[], val: unknown) => {
    state.push(val);
    return state;
  },
  finalize: (state: unknown[]) => JSON.stringify(state),
});

db.exec("SELECT category, json_agg(name) FROM products GROUP BY category");

Custom aggregates are particularly powerful for building specialized reporting queries that would be awkward in pure SQL. You get the grouping and filtering semantics of SQL with the transformation logic of JavaScript.

Keeping Queries Off the Main Thread

For databases with thousands of rows or complex analytical queries, blocking the main thread is not an option. sql.js ships a built-in Web Worker script that provides a message-based interface:

const worker = new Worker("/dist/worker.sql-wasm.js");

worker.postMessage({
  id: 1,
  action: "open",
  buffer: existingDatabaseBuffer,
});

worker.postMessage({
  id: 2,
  action: "exec",
  sql: `
    SELECT category, SUM(price) as total
    FROM products
    GROUP BY category
    HAVING total > 100
  `,
});

worker.onmessage = (event) => {
  if (event.data.id === 2) {
    console.log("Results:", event.data.results);
  }
};

The worker accepts four actions: open, exec, export, and close. This is a simple but effective way to keep your UI responsive while crunching through data.

Persisting Your Database

The database lives entirely in memory by default, which means it vanishes the moment the page reloads. Persistence is your responsibility, but the API makes it straightforward:

function saveToIndexedDB(db: Database): Promise<void> {
  const data = db.export();
  const blob = new Blob([data], { type: "application/x-sqlite3" });

  return new Promise((resolve, reject) => {
    const request = indexedDB.open("myapp", 1);

    request.onupgradeneeded = () => {
      request.result.createObjectStore("databases");
    };

    request.onsuccess = () => {
      const tx = request.result.transaction("databases", "readwrite");
      tx.objectStore("databases").put(blob, "main");
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    };

    request.onerror = () => reject(request.error);
  });
}

function downloadAsFile(db: Database, filename: string): void {
  const data = db.export();
  const blob = new Blob([data], { type: "application/x-sqlite3" });
  const url = URL.createObjectURL(blob);

  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  link.click();

  URL.revokeObjectURL(url);
}

The export() method returns the entire database as a Uint8Array. You can stash it in IndexedDB, localStorage (for small databases), or let the user download it as a real .sqlite file that opens in any SQLite client. The round-trip fidelity is perfect -- what you export can be imported right back.

Things Worth Knowing

The WASM binary weighs roughly 1MB uncompressed, with the JS glue adding another 340KB compressed. That is not trivial for performance-sensitive mobile apps, but for desktop web and internal tools it is perfectly reasonable. The asm.js fallback exists for older browsers but is larger and slower.

The entire database must fit in memory. There is no lazy loading of pages or streaming of rows from disk. For datasets in the thousands to low millions of rows, this is fine. For anything approaching 100MB, you will want to consider alternatives or slice your data accordingly.

Since v1.13, sql.js ships with SQLite 3.49 and is compiled with Emscripten 4.x and LLVM 19. That means you get the latest SQLite query planner improvements, json_pretty(), enhanced iif(), and other recent SQLite additions. The updateHook API added in v1.13 lets you register callbacks for INSERT, UPDATE, and DELETE events, which is useful for building reactive UIs on top of the database.

For Node.js production workloads, native bindings like better-sqlite3 will outperform sql.js by 10-50x. The sweet spot for sql.js is the browser, testing environments, and anywhere you need portable, dependency-free SQL without native compilation.

Your Database, No Server Required

sql.js occupies a unique position in the JavaScript ecosystem. It is not the newest option -- wa-sqlite offers pluggable persistence backends, the official SQLite WASM build has landed, and DuckDB-WASM handles analytical workloads. But sql.js remains the simplest, most battle-tested way to get a full SQL engine running in the browser. Fourteen years of continuous development, the most widely deployed database engine in the world as its foundation, and zero dependencies.

Whether you are building an interactive SQL tutorial, an offline-capable data explorer, a browser extension that needs structured storage, or a prototype that should not need a database server, sql.js gets you there with a single npm install and a few lines of code. Sometimes the best database is the one that already lives in your user's browser tab.