A tiny, worker-first relational database for browser apps.
PostgreSQL-shaped SQL, running locally and away from the main thread.
Small enough to not worry about
The whole database - the main-thread client, the Worker host, and the WebAssembly engine - is 291 KiB gzipped, and only 5 KiB of that ever runs on the UI thread. A build gate keeps the engine itself under 1 MiB uncompressed.
| Component | gzip |
|---|---|
| Main JS | 5 KiB |
| Worker JS | 15 KiB |
| Engine WASM | 271 KiB |
| Everything | 291 KiB |
Your first TinyJoin app
Scaffold a complete local todo app in JS or TS - and with its relational data saved in TinyJoin across reloads - in less than 60s.
> npm create tinyjoin@latest
๐ Welcome to TinyJoin!
๐ฆ Creating your project...
Start small
Install TinyJoin. There are no runtime dependencies, no servers to run, no accounts to create, and no native toolchains to set up.
npm install tinyjoin
Open a database
create() owns Worker construction and WebAssembly loading, and resolves once the database is ready. Use opfs://[name] when data should survive reloads in the same browser, or call it with no argument for ephemeral in-memory storage.
import {create} from 'tinyjoin';
const db = await create('opfs://my-app');
Set up a schema
exec() runs a parameter-free script as one implicit transaction, so schema setup stays a single call that is safe to run again on every load.
await db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT false
)
`);
Write with parameters
query() runs one read or write statement. Application values go in the $n array and never reach the SQL text.
const id = crypto.randomUUID();
await db.query(
'INSERT INTO tasks (id, title) VALUES ($1, $2)',
[id, 'Try TinyJoin'],
);
Or tag a template
The sql tagged template is the same parameterized call in a shorter form. It takes values only, so there is no way to interpolate raw SQL by accident.
const title = 'Written with a tag';
await db.sql`
INSERT INTO tasks (id, title)
VALUES (${crypto.randomUUID()}, ${title})
`;
Read rows back
Results use the familiar rows, fields, affectedRows, command, and rowCount shape. The row type is yours to declare, and TinyJoin adds a database revision and the tables a statement touched.
type Task = {id: string; title: string};
const {rows} = await db.query<Task>(
'SELECT id, title FROM tasks ORDER BY title',
);
Commit related changes together
transaction() stages its writes and publishes them once. Reads inside the callback see the staged rows, and letting an error escape rolls the whole thing back.
await db.transaction(async (tx) => {
await tx.query(
'UPDATE tasks SET done = $1 WHERE id = $2',
[true, id],
);
await tx.query(
'DELETE FROM tasks WHERE done = $1',
[true],
);
});
Re-run without re-parsing
prepare() retains one parsed statement in the Worker. It resolves tables and types against the current catalog on every execution, so a compatible schema change does not make the handle stale.
const openTasks = await db.prepare<{
id: string;
title: string;
}>('SELECT id, title FROM tasks WHERE done = $1');
const {rows} = await openTasks.execute([false]);
Let the view follow the data
subscribe() reports which tables changed, so a UI can re-query instead of being told what to redraw by every writer. close() then releases statements, storage, and the Worker.
const unsubscribe = db.subscribe(
{tables: ['tasks']},
() => render(),
);
// Later
unsubscribe();
await db.close();
Go deeper when you need to
- Follow the getting started guide.
- Browse the API reference.
- Understand the caveats.
- Review the release notes.
- Start an app with create-tinyjoin.
- Read the source.
TinyJoin is MIT licensed.
Local, and deliberately bounded
TinyJoin contains no hosted service, credentials, analytics, or hidden network path. Memory and OPFS use the same page-native database engine. Persistent OPFS storage is single-writer and intended for reconstructable application data; users can still clear or lose browser-managed storage. Tabs using the same database name share that writer automatically. New starter apps also cache their production build for offline reopening.
Important: TinyJoin is also experimental, and implements a deliberately bounded SQL and type subset; it is not PostgreSQL compiled to WebAssembly and has no PostgreSQL server, wire protocol, or replication client. Check the exact SQL compatibility contract and the caveats - experimental status, tab handover, browser support, and the projects to reach for instead - before committing to it.
Meet the family
TinyJoin is one of a group of small libraries that make rich client and local-first apps easier to build. Take a look at the others!
TinyBase A reactive data store with persistence and synchronization.
Synclets An open, storage-agnostic sync engine development kit.
TinyWidgets A collection of tiny, reusable UI components.
TinyTick A tiny but very useful task orchestrator.