Benchmarks
TinyJoin is measured against the two databases most often chosen for the same job: SQLite's official WebAssembly build and PGlite. All three run in a dedicated Worker, store their data in the origin private file system (OPFS), and are driven from the page by the same workloads in the same Chromium.
These numbers are published to track progress, not to win an argument. TinyJoin's engine is young. It is the smallest download and the quickest to open, and it reads every row, runs LIKE scans, groups, joins, builds indexes, and commits single inserts more quickly than either alternative. Most other reads and writes take one to 1.6 times as long as the faster of them. Closing that gap is ongoing work, and the suite is designed to be rerun after every optimization.
Measured on 2026-09-29 with headless Chromium 153.0.8010.12 on macOS (25.5.0), Apple M2, 8 logical CPUs, 16 GiB RAM. Each time is the median of 9 samples.
Every chart has a linear axis from zero, so bar lengths compare directly, and every bar is labeled with its value. In each group, the best value is bold.
Measured results
Download and startup
- Download (uncompressed)
- TinyJoin 735 KiBSQLite 1.0 MiBPGlite 16.6 MiB
- Download (gzip)
- TinyJoin 302 KiBSQLite 457 KiBPGlite 5.6 MiB
- Download (Brotli)
- TinyJoin 245 KiBSQLite 395 KiBPGlite 3.8 MiB
- Load, create an empty database, and read from it
- TinyJoin 55.9 msSQLite 94.0 msPGlite 1.15 s
- Reopen a 10,000-row database in a new session and count it
- TinyJoin 45.5 msSQLite 40.0 msPGlite 219 ms
Download size counts every file a page fetched to open a database: JavaScript for the page and the Worker, WebAssembly, and PGlite's file system image. It is shown uncompressed, compressed with gzip at level 9, and compressed with Brotli at quality 11, since servers send either. First open starts before the engine is fetched and ends when the first query on a new, empty database returns. Reopen runs in a new browser session with a warm HTTP cache: it loads the engine again, opens an existing database, and counts its rows.
Every sample starts in a new browser profile, so each engine's WebAssembly is compiled afresh, one function at a time as each is first called. As it does in any page, TinyJoin's create() also starts a short-lived second Worker that compiles the engine's common statements on a scratch database, so its timed statements mostly run already compiled. SQLite and PGlite compile theirs as they go. See custom Workers.
Create
- 1,000 INSERTs, each committed alone
- TinyJoin 439 msSQLite 1.86 sPGlite 487 ms
- One transaction of 10,000 INSERTs
- TinyJoin 284 msSQLite 200 msPGlite 1.06 s
- One transaction of 10,000 INSERTs, indexed text column
- TinyJoin 290 msSQLite 217 msPGlite 1.10 s
- One transaction of 50 INSERTs, 200 rows each
- TinyJoin 50.2 msSQLite 45.1 msPGlite 103 ms
Read
- 1,000 SELECTs by primary key
- TinyJoin 30.5 msSQLite 29.8 msPGlite 139 ms
- 100 range aggregates, no index
- TinyJoin 46.2 msSQLite 44.2 msPGlite 78.2 ms
- 100 LIKE aggregates on a text column
- TinyJoin 111 msSQLite 141 msPGlite 185 ms
- 100 range aggregates, indexed column
- TinyJoin 3.60 msSQLite 3.60 msPGlite 19.7 ms
- Read all 10,000 rows in order
- TinyJoin 10.5 msSQLite 45.2 msPGlite 44.9 ms
- 10 GROUP BY aggregates over 10,000 rows
- TinyJoin 15.5 msSQLite 31.5 msPGlite 21.5 ms
- 100 joins: one customer’s orders from 5,000
- TinyJoin 16.5 msSQLite 20.0 msPGlite 33.2 ms
Update
- One transaction of 1,000 UPDATEs by primary key
- TinyJoin 37.1 msSQLite 25.2 msPGlite 127 ms
- One transaction of 100 range UPDATEs, no index
- TinyJoin 63.2 msSQLite 49.9 msPGlite 82.2 ms
- One transaction of 1,000 upserts, half of them new
- TinyJoin 37.3 msSQLite 23.6 msPGlite 129 ms
Delete
- One transaction of 1,000 DELETEs by primary key
- TinyJoin 34.4 msSQLite 22.3 msPGlite 126 ms
- One DELETE matching a LIKE pattern
- TinyJoin 5.90 msSQLite 10.9 msPGlite 4.40 ms
- One DELETE of 8,000 rows by indexed range
- TinyJoin 7.80 msSQLite 11.7 msPGlite 6.20 ms
Schema
- Create two indexes over 10,000 rows
- TinyJoin 15.6 msSQLite 22.2 msPGlite 16.3 ms
What the results show
In the results above, TinyJoin is the smallest download and the quickest to create a new database: PGlite initializes a new PostgreSQL cluster on first open. It reads all 10,000 rows in order four times as fast as either engine, runs LIKE scans, GROUP BY, and joins faster than either, builds indexes and commits single inserts faster than either, and deletes rows in bulk faster than SQLite. Indexed range aggregates and point reads take as long as in the faster engine. The rest is slower, but no longer by orders of magnitude: in v0.3.0, most workloads took 10 to 1,400 times as long as the fastest engine, and none now takes as much as twice as long. The remaining gaps point at the work ahead.
- Single statements cost about 30 to 37 microseconds each, which is 1.0 to 1.6 times SQLite's cost for a point read, or an update, upsert, or delete by primary key. About 14 microseconds of every round trip is the message between the page and the Worker, which every engine pays. A point read costs as much as SQLite's, but a write in a transaction costs 12 to 14 microseconds more: in TinyJoin's Worker, in planning, checking, and staging the row, and in its share of the commit.
- Scans take under a tenth longer than SQLite's. TinyJoin reads each column in place from the stored row, and each leaf's rows from its own copy of the leaf, and compares an integer column with integer bounds directly. A range
UPDATEinside a transaction also passes over the rows the transaction has already changed, finding them leaf by leaf, and takes about 1.3 times as long as SQLite's. - Inserts one at a time take about 1.3 to 1.4 times as long as SQLite's, for the same reasons as single statements, and 200 at a time about a tenth longer.
- Bulk deletes take about 1.3 times as long as PGlite's, which, like PostgreSQL, only marks deleted rows and leaves reclaiming their space to a later vacuum. TinyJoin removes each row and its index entries at once, and rewrites every page they occupied, and is still quicker than SQLite at both.
- Committing each insert alone is dominated by the storage flush, one per commit, and writes only the pages the insert changed and the superblock. It takes a tenth less time than PGlite's commits, and a quarter as long as SQLite's.
- Reopening validates every row and index entry, so a populated database reopens a little more slowly than in SQLite.
In memory
The same workloads also run with each engine keeping its database in memory rather than in OPFS: TinyJoin's memory://, SQLite's :memory:, and PGlite's memory://. Nothing reaches storage, so these results measure each engine's own work, and their difference from the results above is what its storage costs. Reopening a database does not apply.
Measured on 2026-09-29 with headless Chromium 153.0.8010.12 on macOS (25.5.0), Apple M2, 8 logical CPUs, 16 GiB RAM. Each time is the median of 9 samples.
Download and startup in memory
- Download (uncompressed)
- TinyJoin 729 KiBSQLite 1.0 MiBPGlite 16.6 MiB
- Download (gzip)
- TinyJoin 299 KiBSQLite 457 KiBPGlite 5.6 MiB
- Download (Brotli)
- TinyJoin 243 KiBSQLite 395 KiBPGlite 3.8 MiB
- Load, create an empty database, and read from it
- TinyJoin 20.3 msSQLite 34.1 msPGlite 798 ms
Create in memory
- 1,000 INSERTs, each committed alone
- TinyJoin 86.8 msSQLite 28.1 msPGlite 126 ms
- One transaction of 10,000 INSERTs
- TinyJoin 266 msSQLite 195 msPGlite 1.04 s
- One transaction of 10,000 INSERTs, indexed text column
- TinyJoin 276 msSQLite 211 msPGlite 1.07 s
- One transaction of 50 INSERTs, 200 rows each
- TinyJoin 53.9 msSQLite 40.2 msPGlite 92.0 ms
Read in memory
- 1,000 SELECTs by primary key
- TinyJoin 29.9 msSQLite 25.8 msPGlite 136 ms
- 100 range aggregates, no index
- TinyJoin 48.1 msSQLite 43.3 msPGlite 78.0 ms
- 100 LIKE aggregates on a text column
- TinyJoin 111 msSQLite 142 msPGlite 181 ms
- 100 range aggregates, indexed column
- TinyJoin 4.10 msSQLite 3.50 msPGlite 18.1 ms
- Read all 10,000 rows in order
- TinyJoin 10.8 msSQLite 45.6 msPGlite 45.3 ms
- 10 GROUP BY aggregates over 10,000 rows
- TinyJoin 16.4 msSQLite 31.5 msPGlite 21.7 ms
- 100 joins: one customer’s orders from 5,000
- TinyJoin 16.3 msSQLite 19.8 msPGlite 31.6 ms
Update in memory
- One transaction of 1,000 UPDATEs by primary key
- TinyJoin 34.6 msSQLite 19.4 msPGlite 123 ms
- One transaction of 100 range UPDATEs, no index
- TinyJoin 62.7 msSQLite 40.7 msPGlite 80.4 ms
- One transaction of 1,000 upserts, half of them new
- TinyJoin 35.3 msSQLite 21.1 msPGlite 127 ms
Delete in memory
- One transaction of 1,000 DELETEs by primary key
- TinyJoin 31.7 msSQLite 17.1 msPGlite 116 ms
- One DELETE matching a LIKE pattern
- TinyJoin 5.10 msSQLite 2.50 msPGlite 3.10 ms
- One DELETE of 8,000 rows by indexed range
- TinyJoin 7.30 msSQLite 4.10 msPGlite 4.70 ms
Schema in memory
- Create two indexes over 10,000 rows
- TinyJoin 14.5 msSQLite 9.20 msPGlite 10.6 ms
In memory, TinyJoin is again the quickest to open, and still reads every row, runs LIKE scans, groups, and joins faster than either engine. Comparing the two sets of results shows what storage costs each engine:
- Committing each insert alone takes TinyJoin about 87 microseconds in memory and 440 with OPFS, so most of an OPFS commit is the storage flush. SQLite commits in memory in about 28 microseconds. TinyJoin still writes, checksums, and records every page each commit changes, as it does for OPFS.
- Bulk deletes and index builds take TinyJoin about as long in memory as with OPFS, since it writes the pages they change in a few large calls, while SQLite's take a quarter to two-fifths as long in memory, and PGlite's a quarter to a third less. What remains is TinyJoin's engine, which takes about 1.8 to 2.0 times as long as the others to delete thousands of rows, and 1.6 times as long to build indexes.
- Single statements and transactions cost about the same either way: a transaction commits once, and a single statement's time is spent between the page, the Worker, and the engine.
The in-memory run followed the run above on the same machine, and each engine's reads took about as long in both. Machine load still differs between runs, so compare engines within one run rather than across the two.
Features are not equivalent
The workloads use only SQL that all three engines accept, which is TinyJoin's bounded dialect. That favors TinyJoin: SQLite and PGlite do far more, and an application that needs what they add should use them. The caveats and the SQL compatibility contract describe TinyJoin's boundaries in full.
| TinyJoin | SQLite | PGlite | |
|---|---|---|---|
| SQL dialect | A bounded, PostgreSQL-shaped subset | SQLite | PostgreSQL |
| Subqueries, CTEs, set operations | No | Yes | Yes |
| Expressions, casts, scalar functions | No | Yes | Yes |
HAVING, window functions | No | Yes | Yes |
| Views, triggers | No | Yes | Yes |
| Joins | Up to eight sources, evaluated as written, through key lookups, indexes, or hash tables | Query planner, with indexes | Query planner, with indexes |
INSERT ... SELECT, UPDATE ... FROM | No | Yes | Yes |
| Upserts | ON CONFLICT, assigning values from EXCLUDED | Yes | Yes |
| Types | Boolean, safe integer, float, text, JSON | Dynamic: integer, real, text, blob | The PostgreSQL type system |
| JSON | Stored, returned, and compared for equality | JSON functions | json and jsonb operators and functions |
| Full-text search | No | FTS5 | tsvector |
| Extensions | No | Compiled in only | PostgreSQL contrib extensions |
| Transactions | A callback API | SQL, with savepoints | SQL, with savepoints |
| Worker | create() owns it | The Worker1 API, or the application's own | PGliteWorker, or the application's own |
| Tabs sharing a database | Automatic, with owner handover | Not with opfs-sahpool, which is exclusive | PGliteWorker leader election |
| Change notifications | Changed tables and primary keys | Update hooks in the C API | Live queries, LISTEN/NOTIFY |
| Node.js | In memory | In memory | In memory or on disk |
| Maturity | Experimental, v0.x | Decades of production use | The PostgreSQL engine, in a v0.x package |
How the benchmark works
The engines
| Engine | Package | Version | Storage |
|---|---|---|---|
| TinyJoin | tinyjoin | 0.3.0 | opfs:// |
| SQLite | @sqlite.org/sqlite-wasm | 3.53.4-build1 (3.53.4) | opfs-sahpool |
| PGlite | @electric-sql/pglite | 0.5.8 (PostgreSQL 18.3) | opfs-ahp:// |
SQLite uses opfs-sahpool, the SQLite project's fastest OPFS file system. Like TinyJoin's storage, it holds its files exclusively, and it needs no cross-origin isolation headers. PGlite uses opfs-ahp, its OPFS file system. Every engine keeps its default durability: no relaxed flushing, no PRAGMA synchronous, and no in-memory journal.
One page, three Workers
A single Vite production build contains a small harness and one chunk per engine, so a page downloads only the engine it opens. Each engine is reached through the same five calls: exec() for a parameter-free script, query() for one statement, prepare() and run() for a repeated statement, and transaction() for a callback.
- TinyJoin's adapter maps those calls onto
create() and theClientAPI, which owns its Worker. - SQLite and PGlite each run in a Worker of a few dozen lines, answering one message per statement. The SQLite Worker caches prepared statements by SQL text; PGlite receives the SQL text with every statement.
- A transaction is one round trip per statement for every engine: SQLite and PGlite send
BEGINandCOMMITas statements, and TinyJoin sends each statement insidetransaction().
Every query result is delivered to the page as an array of row objects, so timings include the message from the Worker, as an application would experience it.
Isolated samples
Every sample launches Chromium with a fresh profile in a new temporary directory. The profile is on disk, not an incognito context, whose storage would be held in memory. No engine ever sees another's files or warm caches, and a sample that exceeds the time limit can be killed without affecting the next. Engines take turns within each round of samples, so gradual changes in machine load affect them equally.
In each sample, the harness opens a new database, runs the workload's untimed setup, and then times only the workload itself with performance.now(). Setup loads rows as multi-row INSERT statements in one transaction.
Checked results
Every workload ends with an untimed check, such as a row count, a column sum, or a total accumulated from the query results. The runner compares the checks of all three engines, and fails if any disagree, so every engine is known to have done the same work.
Workloads
All tables have an integer primary key. The main table has 10,000 rows with a sequential integer, a random integer below 100,000, that number spelled out in English words, and one of 100 group numbers. The data is generated from a fixed seed, so it is identical for every engine and every run.
| Workload | Group | Speed test origin |
|---|---|---|
cold-open: Load, create an empty database, and read from it | Startup | |
reopen: Reopen a 10,000-row database in a new session and count it | Startup | |
insert-autocommit: 1,000 INSERTs, each committed alone | Create | Test 1 |
insert-transaction: One transaction of 10,000 INSERTs | Create | Test 2 |
insert-indexed: One transaction of 10,000 INSERTs, indexed text column | Create | Test 3 |
insert-batch: One transaction of 50 INSERTs, 200 rows each | Create | |
select-pk: 1,000 SELECTs by primary key | Read | |
select-scan: 100 range aggregates, no index | Read | Test 4 |
select-like: 100 LIKE aggregates on a text column | Read | Test 5 |
select-indexed: 100 range aggregates, indexed column | Read | Test 7 |
select-all: Read all 10,000 rows in order | Read | |
group-by: 10 GROUP BY aggregates over 10,000 rows | Read | |
join: 100 joins: one customer’s orders from 5,000 | Read | |
update-pk: One transaction of 1,000 UPDATEs by primary key | Update | Test 9 |
update-scan: One transaction of 100 range UPDATEs, no index | Update | Test 8 |
upsert: One transaction of 1,000 upserts, half of them new | Update | |
delete-pk: One transaction of 1,000 DELETEs by primary key | Delete | |
delete-like: One DELETE matching a LIKE pattern | Delete | Test 12 |
delete-range: One DELETE of 8,000 rows by indexed range | Delete | Test 13 |
create-index: Create two indexes over 10,000 rows | Schema | Test 6 |
Several workloads are adapted from the classic SQLite database speed comparison, which PGlite also publishes results for. The adaptations add a primary key to every table, use smaller counts, and replace the forms TinyJoin cannot run: arithmetic in UPDATE ... SET becomes a literal assignment, and the INSERT ... SELECT tests are omitted.
The join places 5,000 orders across 100 customers, and each query reads one customer's orders through the index on customer_id.
A sample that runs longer than 60 seconds is abandoned and reported as such, and that engine skips the workload's remaining samples.
What this does not measure
- One desktop machine and one browser. Mobile devices, Firefox, and Safari are not measured.
- Assets are served over loopback HTTP, so download sizes are counted, but network transfer time is not.
- Other configurations can be faster or slower: SQLite's
opfsfile system, community builds such as wa-sqlite, and PGlite with IndexedDB orrelaxedDurability. - Concurrency across tabs, very large databases, and long-lived storage fragmentation.
Reproducing the results
The benchmark lives in benchmarks/compare in the repository. SQLite and PGlite are installed there, pinned by its own lockfile, rather than in the project's dependencies. The runner installs them on first use.
npm ci
npm run build
npm run bench:compare -- --publish --samples 9
npm run bench:compare -- --publish --storage memory --samples 9
npm run build:docs
Use the Node.js version in .node-version, since compressed sizes depend on its zlib. A full run of nine samples takes about ten minutes on the machine above. Close other applications, pause background work such as photo library analysis, and avoid concurrent builds or tests while it runs: a busy machine slows every engine, and a burst of load can land on some workloads and not others.
--publish requires the full suite and writes every sample, the environment, and the list of downloaded files to site/data/benchmarks.json, or with --storage memory to site/data/benchmarks-memory.json, from which the documentation build renders these charts.
When working on TinyJoin's performance, run a subset instead. The runner prints each engine's median and TinyJoin's ratio to the fastest:
npm run build
npm run bench:compare -- --engines tinyjoin,sqlite --workloads update-pk,delete-pk --samples 3
--storage memory runs the same workloads without OPFS, which separates the engine's own cost from storage. --out saves a report to compare against a later build, and --help lists every option.