A comprehensive benchmark suite comparing the performance and event loop utilization of different SQLite drivers for Node.js.
Thanks to better-sqlite3/benchmark for the initial work!
This benchmark is using small tables with few columns and little data, therefore low I/O, so it's not reasonable to expect an asynchronous driver to perform in anyway better here. But it is strange, though, that a brief review also highlighted some other “tricks” designed to make the async driver look worse.
- In general, prepared statements were not used for the async driver, but for all others. The performance improvements are significant, e.g 2.4 x for 'select-iterate', 1.5 x for 'insert'
- The async driver had to open an additional database connection for each isolated transaction, even though this is a limitation of SQLite that affects all drivers equally. The performance improvements are significant, e.g 'transaction small' is now about 26x faster
-
Event Loop Integration: Async drivers must integrate with Node.js's event loop, requiring context switches and queue management.
-
Thread Pool Usage: Async SQLite operations are using libuv's thread pool, introducing thread scheduling overhead.
Despite lower raw throughput, async drivers provide Non-Blocking I/O, by preventing the event loop from being blocked and provide Concurrency, by allowing other operations (network requests, file I/O, timers) to proceed while waiting for database operations to complete.
| Driver | Type | Description |
|---|---|---|
better-sqlite3 |
Synchronous | High-performance synchronous SQLite bindings |
@homeofthings/sqlite3 |
Asynchronous | Promise-based SQLite bindings (fork of node-sqlite3) |
node:sqlite |
Synchronous | Built-in Node.js SQLite (Node.js v22.6.0+) |
- Node.js: v20.17.0 or later (for N-API compatibility)
- For
node:sqlite: Node.js v22.6.0+ (experimental) or v22.12.0+ (stable)
npm installnode index.jsThis runs a general-purpose benchmark suite
node index.js <search-terms...>Examples:
# Run only select benchmarks
node index.js select
# Run benchmarks for specific tables
node index.js small
# Run benchmarks for specific columns
node index.js integer text
# Combine search terms
node index.js select small integerTo benchmark the local development version of @homeofthings/sqlite3 instead of the npm package:
node index.js --use-localThis is useful for testing performance changes before publishing. Requires the native addon to be built:
# From project root
npm run buildThe --use-local flag can be combined with search terms:
node index.js --use-local insert small| Type | Description |
|---|---|
select |
Reading single rows by primary key |
select-all |
Reading 100 rows into an array |
select-iterate |
Iterating over 100 rows |
select-aggregate |
Aggregate functions (COUNT, SUM, AVG, MIN, MAX) with WHERE clause |
insert |
Inserting single rows |
update |
Updating single rows |
transaction |
Inserting 100 rows in a single transaction |
update-transaction |
Updating 100 rows in a single transaction |
Results are displayed as:
driver-name x 471,255 ops/sec ±0.07% (event loop: 50%, 2.1μs/op)
x- Separator (from original benchmark format)ops/sec- Operations per second (higher is better)±X.XX%- Relative margin of errorevent loop: X%, Yμs/op- Utilization percentage and blocking time per operation (lower is better)
Running node index.js select produces output like:
select small (nul)
better-sqlite3 x 638,075 ops/sec ±0.44% (event loop: 100%, 1.6μs/op)
@homeofthings/sqlite3 x 88,459 ops/sec ±0.82% (event loop: 47%, 5.3μs/op)
node:sqlite x 543,445 ops/sec ±0.53% (event loop: 100%, 1.8μs/op)
The event loop metrics show how the driver affects the event loop (measured using Node.js native performance.eventLoopUtilization() API):
Utilization Percentage: How much of the benchmark time the event loop was busy (100% = completely blocked, 0% = completely free)
Time per Operation: (1,000,000 μs/sec ÷ ops/sec) × utilization = μs blocked per operation
| Driver | Utilization | Time per Op | Meaning |
|---|---|---|---|
better-sqlite3 |
100% | ~1.6μs/op | Blocks completely - all time is event loop time |
@homeofthings/sqlite3 |
~47% | ~5.3μs/op | 3.3x more blocking than sync drivers |
node:sqlite |
100% | ~1.8μs/op | Blocks completely - all time is event loop time |
Such metric shows the real cost. For some operations, especially on very fast operations lasting only a few microseconds, async drivers may even block the event loop longer in total, even though they don't block it completely for the whole operation, like the sync drivers do. Here, for example, even for very short operations, the event loopis is less burdened by the async driver:
--- inserting rows individually ---
better-sqlite3 x 139,898 ops/sec ±21.94% (event loop: 100%, 7.1μs/op)
@homeofthings/sqlite3 x 47,619 ops/sec ±18.89% (event loop: 22%, 4.6μs/op)
node:sqlite x 128,465 ops/sec ±22.25% (event loop: 100%, 7.8μs/op)
For I/O-bound operations (large data reads), async drivers can actually outperform sync drivers:
--- reading large blobs (16MB each) ---
better-sqlite3 x 83 ops/sec ±7.99% (event loop: 100%, 12.07ms/op)
@homeofthings/sqlite3 x 94 ops/sec ±8.57% (event loop: 34%, 3.63ms/op)
node:sqlite x 127 ops/sec ±10.75% (event loop: 100%, 7.88ms/op)
Why async wins for large data:
- Lower event loop blocking: 3.63ms vs 12.07ms - async driver blocks 70% less
- Higher throughput: 94 vs 83 ops/sec - async driver is 13% faster
- Event loop availability: 66% free during async operations
For I/O-bound operations, the async driver's overhead becomes negligible compared to disk I/O wait time. The ability to interleave other work becomes an advantage - the event loop can process other tasks while waiting for data.
With such a small amount of data we are currently using for this benchmark, it's not so easy to simulate longer running queries. That's why I tried it here using simple aggregation.
Aggregate functions (COUNT, SUM, AVG, MIN, MAX) with WHERE clauses show even more dramatic async advantages:
--- aggregate functions (COUNT, SUM, AVG, MIN, MAX) with WHERE clause ---
better-sqlite3 x 11,246 ops/sec ±0.27% (event loop: 100%, 88.9μs/op)
@homeofthings/sqlite3 x 68,779 ops/sec ±0.60% (event loop: 47%, 6.8μs/op)
node:sqlite x 10,982 ops/sec ±0.40% (event loop: 100%, 91.1μs/op)
Why async wins for aggregation:
- 6x higher throughput: 68,779 vs 11,246 ops/sec
- 13x less event loop blocking: 6.8μs/op vs 88.9μs/op
- Same pattern as large data: I/O-bound operations benefit from async
Aggregation queries scan 1000 rows per operation. The async driver's ability to yield during I/O makes it significantly more efficient for these multi-row operations.
├── index.js # Main orchestrator
├── benchmark.js # Benchmark runner (tinybench)
├── drivers.js # SQLite driver configurations
├── trials.js # Benchmark trial definitions
├── seed.js # Database seeding
├── types/
│ ├── insert.js # Insert benchmark
│ ├── select.js # Single row select benchmark
│ ├── select-all.js # Multi-row select benchmark
│ ├── select-aggregate.js # Aggregate functions benchmark
│ ├── select-iterate.js # Iteration benchmark
│ └── transaction.js # Transaction benchmark
└── temp/ # Temporary database files (auto-created)
Each benchmark runs in an isolated child process to ensure:
- Clean state for each measurement
- Memory isolation between runs
- No interference between drivers
- Add the driver to
package.jsondependencies - Add a connection function to
drivers.js:
['driver-name', async (filename, pragma) => {
const db = require('driver-package')(filename);
// Apply PRAGMA settings
for (const str of pragma) await db.exec(`PRAGMA ${str}`);
return db;
}]- Add benchmark implementations in each
types/*.jsfile:
// Either return sync or async function
exports['driver-name'] = (db, ctx) => {
return () => db.someOperation();
};
exports['driver-name'] = async (db, ctx) => {
return () => db.someOperation();
};