better-sqlite3 vs libsql vs sql.js: SQLite in Node.js (2026)
TL;DR
better-sqlite3 is the fastest SQLite driver for Node.js — synchronous API (which is actually ideal for SQLite's single-writer model), C++ native extension, and used by default in Drizzle ORM's SQLite mode. libsql (from Turso) is a fork of SQLite with extensions — it powers the Turso serverless SQLite platform and supports both local file mode and remote HTTP connections. sql.js compiles SQLite to WebAssembly — runs in the browser, Cloudflare Workers, and any WASM-compatible environment, but requires loading a ~1MB WASM binary. In 2026: better-sqlite3 for server-side Node.js apps, libsql for Turso-connected or edge deployments, sql.js for browser-side SQLite.
Key Takeaways
- better-sqlite3: ~3M weekly downloads — synchronous API, fastest Node.js SQLite driver, native C++
- libsql: ~1M weekly downloads — Turso's SQLite fork, HTTP remote connections, edge-compatible
- sql.js: ~500K weekly downloads — WebAssembly SQLite, browser + Workers, no native deps
- SQLite is synchronous by design — better-sqlite3's sync API matches this perfectly
- Bun has built-in SQLite (
bun:sqlite) — no library needed, same API as better-sqlite3 - libsql supports
libsql://URLs for Turso cloud +file:for local SQLite
SQLite Use Cases in 2026
When SQLite makes sense:
- Development databases (no setup, just a .db file)
- Edge deployments (low-overhead, embeddable)
- Desktop apps with Electron/Tauri
- Serverless functions with local storage (Cloudflare D1, Turso)
- Analytics and read-heavy workloads
- Single-region apps with moderate traffic
When to use PostgreSQL/MySQL instead:
- Multiple concurrent writers at high volume
- Multi-region horizontal scaling
- Complex transactions across many tables
- Team of developers hitting same DB concurrently
better-sqlite3
better-sqlite3 — the fast synchronous SQLite driver:
Basic usage
import Database from "better-sqlite3"
// Open (or create) a database:
const db = new Database("pkgpulse.db")
// Or: in-memory database:
const memdb = new Database(":memory:")
// Create tables:
db.exec(`
CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
version TEXT NOT NULL,
weekly_downloads INTEGER DEFAULT 0,
health_score REAL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_packages_name ON packages(name);
CREATE INDEX IF NOT EXISTS idx_packages_health ON packages(health_score);
`)
// Insert:
const insert = db.prepare(`
INSERT INTO packages (name, version, weekly_downloads, health_score)
VALUES (@name, @version, @weeklyDownloads, @healthScore)
`)
insert.run({
name: "react",
version: "18.3.1",
weeklyDownloads: 25_000_000,
healthScore: 92.5,
})
// Prepared statements are fast — prepare once, run many times:
const insertMany = db.transaction((packages: Package[]) => {
for (const pkg of packages) {
insert.run(pkg)
}
})
insertMany(packageList) // All inserted in a single transaction
Queries
import Database from "better-sqlite3"
const db = new Database("pkgpulse.db")
// Get one row:
const getPackage = db.prepare("SELECT * FROM packages WHERE name = ?")
const react = getPackage.get("react")
// { id: 1, name: "react", version: "18.3.1", weekly_downloads: 25000000, health_score: 92.5 }
// Get all matching rows:
const getHealthy = db.prepare(`
SELECT * FROM packages
WHERE health_score >= @minScore
ORDER BY weekly_downloads DESC
LIMIT @limit
`)
const healthy = getHealthy.all({ minScore: 80, limit: 20 })
// Returns array of row objects
// Iterate without loading all into memory:
const stmt = db.prepare("SELECT * FROM packages ORDER BY weekly_downloads DESC")
for (const pkg of stmt.iterate()) {
console.log(pkg.name, pkg.weekly_downloads)
}
// Count:
const { count } = db.prepare("SELECT COUNT(*) as count FROM packages").get() as { count: number }
Transactions
// better-sqlite3 transactions are synchronous and fast:
const updateStats = db.transaction((updates: Array<{ name: string; downloads: number }>) => {
const stmt = db.prepare(`
UPDATE packages SET weekly_downloads = @downloads WHERE name = @name
`)
let updated = 0
for (const { name, downloads } of updates) {
const result = stmt.run({ name, downloads })
updated += result.changes
}
return updated // Return value from transaction function
})
const count = updateStats(downloadUpdates)
console.log(`Updated ${count} packages`)
WAL mode and pragmas
import Database from "better-sqlite3"
const db = new Database("pkgpulse.db")
// Enable WAL mode — better concurrent read performance:
db.pragma("journal_mode = WAL")
// Performance pragmas:
db.pragma("synchronous = NORMAL") // Faster writes (vs FULL), safe with WAL
db.pragma("cache_size = -64000") // 64MB page cache
db.pragma("temp_store = MEMORY") // Temp tables in memory
db.pragma("mmap_size = 268435456") // 256MB memory-mapped I/O
// Check integrity:
const result = db.pragma("integrity_check")
console.log(result) // [{integrity_check: "ok"}]
// Close:
process.on("exit", () => db.close())
libsql
libsql / @libsql/client — SQLite with superpowers:
Local SQLite mode (compatible with better-sqlite3)
import { createClient } from "@libsql/client"
// Local file — same as SQLite:
const db = createClient({ url: "file:pkgpulse.db" })
// In-memory:
const memdb = createClient({ url: ":memory:" })
// Async API (unlike better-sqlite3):
await db.execute(`
CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
health_score REAL DEFAULT 0
)
`)
await db.execute({
sql: "INSERT INTO packages (name, health_score) VALUES (?, ?)",
args: ["react", 92.5],
})
const result = await db.execute("SELECT * FROM packages WHERE health_score > 80")
console.log(result.rows)
// [{ id: 1, name: "react", health_score: 92.5 }]
Turso remote connection
import { createClient } from "@libsql/client"
// Connect to Turso cloud database:
const db = createClient({
url: process.env.TURSO_DATABASE_URL!, // libsql://dbname.turso.io
authToken: process.env.TURSO_AUTH_TOKEN!,
})
// Embedded replicas (local SQLite + sync with Turso cloud):
const embeddedDb = createClient({
url: "file:local-replica.db", // Local SQLite file
syncUrl: process.env.TURSO_DATABASE_URL!, // Primary in Turso cloud
authToken: process.env.TURSO_AUTH_TOKEN!,
})
// Sync local replica with cloud primary:
await embeddedDb.sync()
// Now reads are local (fast), writes go to cloud and sync back
With Drizzle ORM
import { drizzle } from "drizzle-orm/libsql"
import { createClient } from "@libsql/client"
import { text, integer, sqliteTable } from "drizzle-orm/sqlite-core"
import { eq, gt } from "drizzle-orm"
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
})
const db = drizzle(client)
const packages = sqliteTable("packages", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
healthScore: integer("health_score"),
})
// Type-safe queries:
const result = await db
.select()
.from(packages)
.where(gt(packages.healthScore, 80))
.limit(20)
sql.js
sql.js — SQLite compiled to WebAssembly:
Browser usage
import initSqlJs from "sql.js"
// Load WASM binary (must be served as a static asset):
const SQL = await initSqlJs({
locateFile: (file) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/${file}`,
})
// Create in-memory database:
const db = new SQL.Database()
// Execute SQL:
db.run(`
CREATE TABLE packages (id INTEGER PRIMARY KEY, name TEXT, score REAL);
INSERT INTO packages VALUES (1, 'react', 92.5);
INSERT INTO packages VALUES (2, 'vue', 89.0);
`)
// Query:
const stmt = db.prepare("SELECT * FROM packages WHERE score > $minScore")
stmt.bind({ $minScore: 85 })
while (stmt.step()) {
const row = stmt.getAsObject()
console.log(row) // { id: 1, name: "react", score: 92.5 }
}
stmt.free()
// Export database as binary (save to IndexedDB or download):
const data = db.export()
const buffer = Buffer.from(data)
// Or: localStorage.setItem("db", btoa(String.fromCharCode(...data)))
Load existing SQLite file in browser
// Fetch a .db file and open it:
const response = await fetch("/data/packages.db")
const arrayBuffer = await response.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
const SQL = await initSqlJs({ locateFile: (f) => `/wasm/${f}` })
const db = new SQL.Database(uint8Array)
const result = db.exec("SELECT name, score FROM packages ORDER BY score DESC LIMIT 10")
// [{columns: ["name", "score"], values: [["react", 92.5], ["vue", 89.0]]}]
Feature Comparison
| Feature | better-sqlite3 | libsql | sql.js |
|---|---|---|---|
| API style | Synchronous | Async | Sync |
| Browser support | ❌ | ❌ | ✅ |
| Cloudflare Workers | ❌ | ✅ (D1/Turso) | ✅ |
| Remote database | ❌ | ✅ Turso | ❌ |
| Native deps | ✅ C++ | ✅ C++ | ❌ WASM |
| Bun compatibility | ✅ | ✅ | ✅ |
| ORM support | ✅ (Drizzle, Prisma) | ✅ (Drizzle) | ❌ |
| WAL mode | ✅ | ✅ | ❌ |
| TypeScript | ✅ | ✅ | ✅ |
| Weekly downloads | ~3M | ~1M | ~500K |
When to Use Each
Choose better-sqlite3 if:
- Server-side Node.js app (not browser/edge)
- Want maximum SQLite performance
- Using Drizzle ORM or Prisma with SQLite
- Building a desktop app (Electron, Tauri) with SQLite storage
Choose libsql if:
- Using Turso for serverless SQLite hosting
- Need embedded replica pattern (local reads + cloud writes)
- Deploying to edge environments that support the libsql HTTP protocol
- Want a migration path from local SQLite to cloud-hosted
Choose sql.js if:
- Browser-side SQLite (offline web apps, client-side data processing)
- Cloudflare Workers with SQLite in WASM
- Need to open existing .db files in the browser
- Deno or environments without native module support
Also consider:
- Bun's built-in SQLite (
import { Database } from "bun:sqlite") — zero-dep, better-sqlite3-compatible API, fastest option in Bun - Cloudflare D1 — uses SQLite API, global distribution, REST HTTP access
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on better-sqlite3 v9.x, @libsql/client v0.x, and sql.js v1.x.