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 or reopen a database, and it reads every row, reads rows by key or through an index, scans, runs LIKE scans, groups, joins, inserts rows one at a time or 200 at a time, updates rows by key or by range, upserts, deletes rows by key, by pattern or by range, builds indexes, and commits single inserts more quickly than either alternative. With OPFS storage, it is the fastest of the three in all 20 workloads. Two of those leads are of 1% or 2%, over SQLite at inserts in a transaction and over PGlite at the range delete, and another run could reverse them. The suite is designed to be rerun after every optimization.

Measured on 2026-10-08 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.

Each workload lists the engines fastest first, and is drawn to its own linear scale from zero, on which its slowest run reaches the full width, so a quick workload's bars are as legible as a slow one's. A bar's length and value are the median of an engine's runs, and a faint bracket at its end spans that engine's fastest run to its slowest.

Download

Bytes fetched to open a database, smallest first.
Download (uncompressed)
TinyJoin852 KiBSQLite1.0 MiBPGlite16.6 MiB
Download (gzip)
TinyJoin354 KiBSQLite457 KiBPGlite5.6 MiB
Download (Brotli)
TinyJoin292 KiBSQLite395 KiBPGlite3.8 MiB

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.

Startup

Median of 9 runs, fastest first. Brackets span the fastest to the slowest run.
Load, create an empty database, and read from it
TinyJoin55.8 msSQLite83.2 msPGlite1.12 s
Reopen a 10,000-row database in a new session and count it
TinyJoin23.4 msSQLite39.2 msPGlite219 ms

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, made in a directory Spotlight does not index and removed afterwards, 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.

Compiled is not yet optimized. A browser optimizes a function only once it has run many times, and every timed workload is the first run of its statements in its page, so every engine's statements run faster when run again: a thousand point reads by key take TinyJoin about 21 ms and SQLite about 25 ms by the fifth run in one page, against 27 and 29 the first time. See custom Workers.

Create

Median of 9 runs, fastest first. Brackets span the fastest to the slowest run.
1,000 INSERTs, each committed alone
TinyJoin383 msPGlite441 msSQLite1.46 s
One transaction of 10,000 INSERTs
TinyJoin197 msSQLite199 msPGlite1.06 s
One transaction of 10,000 INSERTs, indexed text column
TinyJoin205 msSQLite213 msPGlite1.09 s
One transaction of 50 INSERTs, 200 rows each
TinyJoin35.7 msSQLite44.7 msPGlite102 ms

Read

Median of 9 runs, fastest first. Brackets span the fastest to the slowest run.
1,000 SELECTs by primary key
TinyJoin21.1 msSQLite29.7 msPGlite138 ms
100 range aggregates, no index
TinyJoin24.1 msSQLite44.2 msPGlite78.8 ms
100 LIKE aggregates on a text column
TinyJoin68.3 msSQLite141 msPGlite182 ms
100 range aggregates, indexed column
TinyJoin2.90 msSQLite3.50 msPGlite19.5 ms
Read all 10,000 rows in order
TinyJoin6.90 msPGlite44.7 msSQLite45.3 ms
10 GROUP BY aggregates over 10,000 rows
TinyJoin15.5 msPGlite21.4 msSQLite31.6 ms
100 joins: one customer’s orders from 5,000
TinyJoin16.6 msSQLite20.0 msPGlite32.7 ms

Update

Median of 9 runs, fastest first. Brackets span the fastest to the slowest run.
One transaction of 1,000 UPDATEs by primary key
TinyJoin22.0 msSQLite24.2 msPGlite127 ms
One transaction of 100 range UPDATEs, no index
TinyJoin37.1 msSQLite48.3 msPGlite82.0 ms
One transaction of 1,000 upserts, half of them new
TinyJoin20.1 msSQLite22.1 msPGlite126 ms

Delete

Median of 9 runs, fastest first. Brackets span the fastest to the slowest run.
One transaction of 1,000 DELETEs by primary key
TinyJoin20.0 msSQLite20.8 msPGlite120 ms
One DELETE matching a LIKE pattern
TinyJoin3.50 msPGlite4.10 msSQLite9.50 ms
One DELETE of 8,000 rows by indexed range
TinyJoin5.90 msPGlite6.00 msSQLite10.2 ms

Schema

Median of 9 runs, fastest first. Brackets span the fastest to the slowest run.
Create two indexes over 10,000 rows
TinyJoin13.0 msPGlite16.3 msSQLite20.4 ms

What the results show

In the results above, TinyJoin is the smallest download and the quickest to create a new database, where PGlite initializes a new PostgreSQL cluster, and to reopen one. It reads all 10,000 rows in order six times as fast as either engine, reads rows by key in three-tenths less time than SQLite, runs indexed range aggregates in a sixth less, runs LIKE scans, GROUP BY, and joins faster than either, inserts, updates, upserts, and deletes rows faster than either, and builds indexes and commits single inserts faster than either. Across the 20 timed workloads, it places like this, where engines that tie share a place:

EngineFastestSecondSlowest
TinyJoin2000
SQLite0146
PGlite0614

Its narrowest leads are 1% over SQLite at 10,000 inserts in a transaction, and 2% over PGlite at the range delete.

  • Single statements cost about 20 to 22 microseconds each. About 12 microseconds of every round trip is the message between the page and the Worker, which every engine pays. A statement and its result cross as flat arrays, which cost the least to copy from one thread to the other, and a write's result is never text to parse. A point read by primary key takes three-tenths less time than SQLite's, an update or upsert by primary key about 9% less, and a delete by primary key about 4% less. The benchmark's 1,000 statements run JavaScript and WebAssembly that V8 has mostly not yet optimized, which runs TinyJoin's many small functions almost twice as slowly as optimized code, where SQLite's few large functions are optimized almost at once.
  • Scans take about three-fifths of SQLite's time. A predicate's simple terms are tested against each leaf's rows in one pass over the leaf's bytes, and only the rows they accept are decoded. A range UPDATE inside a transaction also passes over the rows the transaction has already changed, finding them leaf by leaf, and takes about four-fifths of SQLite's time.
  • Inserts one at a time in a transaction take 1% less time than SQLite's, 4% less with an index on the table, and 200 at a time a fifth less.
  • Bulk deletes take about a seventh less time than PGlite's by pattern, and about as long by range. PGlite, 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, copying the rows a page keeps in runs, and takes under three-fifths of SQLite's time 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 about an eighth less time than PGlite's commits, and about a quarter as long as SQLite's. Its time is mostly the flushes, so it varies most of any workload with the state of the disk, which is why the runner waits for a flush to run at its usual latency before each sample.
  • Reopening reads the database's catalog rather than every row, so a populated database reopens in under three-fifths of SQLite's time, and a large one as quickly as a small one. The full check of every row and index entry runs only when an application calls check().

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 operationsUncorrelated IN (SELECT ...), and nested queries that gather rows into JSONYesYes
Expressions, casts, scalar functionsArithmetic and ||, without casts or functionsYesYes
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 expressions of the stored and proposed rowsYesYes
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.6.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, in an order that changes every round through every arrangement of the three, so that no engine always follows the same one and gradual changes in machine load affect them equally. Before each round, the runner times a short computation, and waits until it runs within 5% of its time at the start: a laptop slows as it heats over a long run, and background work takes its cores, and either would otherwise weigh on whichever workloads ran then.

Before each sample, once the previous sample's profile has been deleted, the runner also times a small write and flush to the disk that holds the browser profiles, and waits until it runs at its usual latency. A flush takes its turn in the drive's queue behind whatever else is being written to the volume, so deleting a profile, which for PGlite holds a pool of many files, or other work on the disk can slow every engine's commits for seconds at a time, which the computation would not show. The flush it times is the one the engines pay for: on macOS, Chromium's OPFS flush is a barrier that orders the drive's queue, not a full flush of its cache. The wait is capped, and the report records how many samples began regardless, whether the runner gave up waiting, and the reading taken before each sample.

In the run shown, the CPU probe took 18.6 ms at the start and 24.9 ms at its slowest, and the runner waited 590 s in all for the CPU, and 2 rounds began before it had recovered. A write and flush usually took 0.51 ms, and 0.79 ms at its slowest, and the runner waited 0 s in all for the disk.

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 could not run when they were written: arithmetic in UPDATE ... SET becomes a literal assignment, and the INSERT ... SELECT tests, which it still cannot run, 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 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. The runner prints how long its CPU and flush probes took at the start and at their slowest, and how long it waited for each to recover. --probe 60 times both once a second for a minute without running anything else, which shows whether the machine is quiet before a long run.

--publish requires the full suite and writes every sample, the environment, and the list of downloaded files to site/data/benchmarks.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

--out saves a report to compare against a later build, and --help lists every option.