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
Performance Characteristics and SQLite's Synchronous Advantage
better-sqlite3's synchronous API is not just an API design choice — it maps directly to SQLite's execution model. SQLite processes queries synchronously in the calling thread, and the database file has a single-writer lock that serializes writes. There is no benefit to an asynchronous API for SQLite because there is no I/O overlap to achieve: the database processes your query, writes to disk, and returns. An async wrapper would just add Promise overhead without improving throughput. This is why better-sqlite3's benchmarks consistently outperform async SQLite wrappers — it removes the event loop overhead that async APIs require. The synchronous model also simplifies reasoning about transaction behavior: within a db.transaction() call, all operations are atomic and isolated from other readers, with no risk of interleaved async operations from other request handlers interfering. For Node.js APIs that receive multiple concurrent requests, this means SQLite's single-writer model naturally serializes write requests without explicit mutex logic — a simplification compared to multi-writer databases like PostgreSQL.
Migration Paths Between SQLite Libraries
Projects sometimes need to migrate between these SQLite libraries as requirements change. Moving from better-sqlite3 to libsql is the most common migration path when a team adopts Turso for hosted SQLite. The libsql client's local file mode (url: "file:mydb.db") is a drop-in replacement for most better-sqlite3 use cases, with the main change being that all queries are async (return promises) rather than synchronous. Codebases that use the synchronous API throughout must be updated to use await at each query call site — this is mechanical work that can be done incrementally. Moving from sql.js to better-sqlite3 typically happens when a proof-of-concept built in the browser or with WASM needs to move to a production server-side deployment. The SQL syntax is identical between all three since they all use the same SQLite engine; only the client API differs. Moving from better-sqlite3 to Bun's built-in SQLite (bun:sqlite) requires minimal code changes — the API is intentionally compatible with better-sqlite3, and most migrations are straightforward substitutions.
SQLite in Multi-Tenant and Edge Architectures
SQLite's architecture is particularly well-suited to certain multi-tenant patterns that relational databases with shared schemas struggle with. The "database per tenant" pattern — where each customer gets their own SQLite file — provides strong data isolation with no query-level access control complexity. Adding a new tenant is as simple as creating a new file. Turso's libsql platform is built around this pattern: each Turso database is an independent SQLite file hosted in their infrastructure, and the per-database pricing model makes thousands of small databases economically viable. Cloudflare D1, also built on SQLite, follows the same model at the edge — each D1 database is isolated per account. sql.js makes the database-per-tenant pattern available in the browser, where each user session can have its own in-memory SQLite database for local data without any server state. This eliminates many of the synchronization and conflict resolution challenges of shared cloud databases for single-user applications. The tradeoff is that cross-tenant queries or analytics across all tenants' data require a separate aggregation layer since each SQLite file is independent.
Security Considerations for Embedded SQLite
Running SQLite embedded in your application reduces the network attack surface compared to client-server databases — there is no database network port to expose, no authentication handshake to intercept, and no SQL injection through a separate process boundary. However, embedded SQLite introduces different security considerations. The database file itself must be protected at the filesystem level — if an attacker can read the .db file, they have all your data. File permissions (chmod 600) and disk encryption are the primary controls. SQL injection remains possible if you construct queries with string concatenation rather than parameterized queries: all three libraries support parameterized queries (the ? placeholder syntax in better-sqlite3 and sql.js, the args array in libsql), and you should always use them for user-supplied values. SQLite's ATTACH DATABASE command can open arbitrary database files from within a query, which is a potential privilege escalation vector if your application accepts SQL from untrusted sources — most applications should not allow raw SQL execution and should restrict access to specific prepared statements.
Choosing Between SQLite and PostgreSQL for New Projects
The decision to use SQLite versus PostgreSQL for a new project is often framed as "SQLite for small projects, PostgreSQL for production," but this framing undervalues SQLite's legitimate strengths at scale. Read-heavy applications — content sites, analytics dashboards, API documentation — can run SQLite in production at significant traffic levels because SQLite's WAL mode allows concurrent readers without blocking. The SQLite documentation notes that SQLite handles 100K+ requests per day without difficulty, and several teams have reported running production applications serving millions of daily reads on SQLite. The genuine limitations are write concurrency (SQLite's single-writer model creates queuing for write-heavy workloads), multi-machine shared access (SQLite is file-based; multiple servers cannot share the same database without network file storage), and replication (no built-in replication means backup strategies are file-based). For APIs that are write-heavy (user-generated content, e-commerce transactions, IoT data ingestion), PostgreSQL is the better foundation from the start. For content-heavy read-dominant applications, SQLite with better-sqlite3 or libsql deserves serious consideration as the simpler option that is operationally easier to run.
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.
Compare database driver packages on PkgPulse →
See also: Lit vs Svelte and MikroORM vs Sequelize vs Objection.js, casl vs casbin vs accesscontrol.