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 Rust WASM engine - is 339 KiB gzipped, and only 7 KiB of that ever runs on the UI thread.
And it's quite fast! Check our Benchmarks guide for empirical comparisons with other client databases.
| Component | gzip |
|---|---|
| Main JS | 7 KiB |
| Worker JS | 15 KiB |
| Engine WASM | 317 KiB |
| Everything | 339 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. Write its queries in SQL, or with the Drizzle ORM or the Kysely query builder.
> npm create tinyjoin@latest
🎉 Welcome to TinyJoin!
✔ Project name: … my-tinyjoin-app
✔ Language: › TypeScript
✔ Queries: › Drizzle ORM
✔ Todo data: › Save data across reloads (recommended)
✔ Install dependencies and start the app? … yes
📦 Creating your project...
Start small
Install TinyJoin. There are no runtime dependencies, no servers to run, no accounts to create, and no native toolchains
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]);
Or bring a query builder
The Drizzle ORM and the Kysely query builder run on the same Client. push() makes the database hold a Drizzle schema as the app starts, in one atomic change that keeps every row. And npm create tinyjoin@latest can start a new app with either.
import {drizzle, push} from 'tinyjoin/drizzle';
import {eq} from 'drizzle-orm';
import * as schema from './schema';
const orm = drizzle(db, {schema});
await push(orm, schema);
const open = await orm
.select()
.from(schema.tasks)
.where(eq(schema.tasks.done, 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.
Local by design
TinyJoin keeps your database in the browser. Your app reads and writes data locally, without waiting for a database server. Keep data in memory or save it across reloads with OPFS; tabs opening the same persistent database share access automatically.
Your app can keep working when the network drops. Apps created with the starter can also reopen offline once their production build has been cached.
An important warning
TinyJoin is experimental and verified on Chromium only, with a bounded SQL dialect and no built-in remote synchronization (yet!). Browser storage can be lost, so use it for data you can reconstruct. Read the caveats and SQL compatibility guide to check whether this project currently fits your app. We're working on it though, so keep checking back.
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.