TinyJoin logoTinyJoin

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

TinyJoinSQLitePGliteBytes fetched to open a database. Shorter is better.
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
Median of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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 UPDATE inside 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

TinyJoinSQLitePGliteBytes fetched to open a database. Shorter is better.
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
Median of 9 runs. Shorter is better.
Load, create an empty database, and read from it
TinyJoin 20.3 msSQLite 34.1 msPGlite 798 ms

Create in memory

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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

TinyJoinSQLitePGliteMedian of 9 runs. Shorter is better.
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.

TinyJoinSQLitePGlite
SQL dialectA bounded, PostgreSQL-shaped subsetSQLitePostgreSQL
Subqueries, CTEs, set operationsNoYesYes
Expressions, casts, scalar functionsNoYesYes
HAVING, window functionsNoYesYes
Views, triggersNoYesYes
JoinsUp to eight sources, evaluated as written, through key lookups, indexes, or hash tablesQuery planner, with indexesQuery planner, with indexes
INSERT ... SELECT, UPDATE ... FROMNoYesYes
UpsertsON CONFLICT, assigning values from EXCLUDEDYesYes
TypesBoolean, safe integer, float, text, JSONDynamic: integer, real, text, blobThe PostgreSQL type system
JSONStored, returned, and compared for equalityJSON functionsjson and jsonb operators and functions
Full-text searchNoFTS5tsvector
ExtensionsNoCompiled in onlyPostgreSQL contrib extensions
TransactionsA callback APISQL, with savepointsSQL, with savepoints
Workercreate() owns itThe Worker1 API, or the application's ownPGliteWorker, or the application's own
Tabs sharing a databaseAutomatic, with owner handoverNot with opfs-sahpool, which is exclusivePGliteWorker leader election
Change notificationsChanged tables and primary keysUpdate hooks in the C APILive queries, LISTEN/NOTIFY
Node.jsIn memoryIn memoryIn memory or on disk
MaturityExperimental, v0.xDecades of production useThe PostgreSQL engine, in a v0.x package

How the benchmark works

The engines

EnginePackageVersionStorage
TinyJointinyjoin0.3.0opfs://
SQLite@sqlite.org/sqlite-wasm3.53.4-build1 (3.53.4)opfs-sahpool
PGlite@electric-sql/pglite0.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 the Client API, 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 BEGIN and COMMIT as statements, and TinyJoin sends each statement inside transaction().

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.

WorkloadGroupSpeed test origin
cold-open: Load, create an empty database, and read from itStartup
reopen: Reopen a 10,000-row database in a new session and count itStartup
insert-autocommit: 1,000 INSERTs, each committed aloneCreateTest 1
insert-transaction: One transaction of 10,000 INSERTsCreateTest 2
insert-indexed: One transaction of 10,000 INSERTs, indexed text columnCreateTest 3
insert-batch: One transaction of 50 INSERTs, 200 rows eachCreate
select-pk: 1,000 SELECTs by primary keyRead
select-scan: 100 range aggregates, no indexReadTest 4
select-like: 100 LIKE aggregates on a text columnReadTest 5
select-indexed: 100 range aggregates, indexed columnReadTest 7
select-all: Read all 10,000 rows in orderRead
group-by: 10 GROUP BY aggregates over 10,000 rowsRead
join: 100 joins: one customer’s orders from 5,000Read
update-pk: One transaction of 1,000 UPDATEs by primary keyUpdateTest 9
update-scan: One transaction of 100 range UPDATEs, no indexUpdateTest 8
upsert: One transaction of 1,000 upserts, half of them newUpdate
delete-pk: One transaction of 1,000 DELETEs by primary keyDelete
delete-like: One DELETE matching a LIKE patternDeleteTest 12
delete-range: One DELETE of 8,000 rows by indexed rangeDeleteTest 13
create-index: Create two indexes over 10,000 rowsSchemaTest 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 opfs file system, community builds such as wa-sqlite, and PGlite with IndexedDB or relaxedDurability.
  • 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.