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
- 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
- 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
- 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
- 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
- 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
- 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
- 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:
| Engine | Fastest | Second | Slowest |
|---|---|---|---|
| TinyJoin | 20 | 0 | 0 |
| SQLite | 0 | 14 | 6 |
| PGlite | 0 | 6 | 14 |
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
UPDATEinside 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.
| TinyJoin | SQLite | PGlite | |
|---|---|---|---|
| SQL dialect | A bounded, PostgreSQL-shaped subset | SQLite | PostgreSQL |
| Subqueries, CTEs, set operations | Uncorrelated IN (SELECT ...), and nested queries that gather rows into JSON | Yes | Yes |
| Expressions, casts, scalar functions | Arithmetic and ||, without casts or functions | 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 expressions of the stored and proposed rows | 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.6.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, 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.
| 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 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
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 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.