Evolu is using Kysely (opens in a new tab), the type-safe SQL query builder for TypeScript.

Kysely IS NOT an ORM. Kysely DOES NOT have the concept of relations. Kysely IS a query builder. Kysely DOES build the SQL you tell it to, nothing more, nothing less.

However, there is a way to nest related rows in queries. It's described here (opens in a new tab), and Evolu supports it.

TLDR: JSON type with subselects. With the combination of these two things, we can write some super efficient queries with nested relations. Evolu automatically parses stringified JSONs to typed objects. Evolu also ensures that no strings can accidentally contain a stringified JSON.

Relations are helpful to avoid loading waterfalls. Check Next.js example (opens in a new tab) to see how each todo fetches its categories.

Deferred Sync with Local-Only Tables

Tables with a name prefixed with _ are local-only, which means they are never synced. It's useful for device-specific or temporal data.

Imagine editing a JSON representing a rich-text formatted document. Syncing the whole document on every change would be inefficient. The ideal solution could be to use some advanced CRDT logic, for example, the Peritext (opens in a new tab), but a reliable implementation doesn't exist yet.

Fortunately, we can leverage Evolu's local-only tables instead. Saving huge JSON on every keystroke isn't an issue because Evolu uses Web Workers, so saving doesn't block the main thread. In React Native, we use InteractionManager.runAfterInteractions (soon).

Is postMessage slow? No, not really. (It depends.) (opens in a new tab)

When we decide it's time to sync, we move data from the local-only table to the regular table. There is no API for that; just set isDeleted to true and insert data into a new table. Evolu batches mutations in microtask and runs it within a transaction, so there is no chance for data loss.

// Both `update` and `create` run within a transaction.
evolu.update("_todo", { id: someId, isDeleted: true });
// This mutation starts syncing immediately.
evolu.create("todo", { title });

The last question is, when should we do that? We can expose an explicit sync button, but that's not a friendly UX. The better approach is to use a reliable heuristic to detect the user unit of work. We can leverage page visibility, a route change, and other techniques. Unfortunately, we can't rely on unload event because it's unreliable. Evolu will release a helper for that soon.


SQLite supports JSON but stores and returns it as a stringified object. Evolu automatically stringifies and parses JSON to improve DX.

const SomeJson = S.struct({ foo: S.string, bar: S.boolean });
type SomeJson = S.Schema.To<typeof SomeJson>;
const TodoTable = table({
  id: TodoId,
  title: NonEmptyString1000,
  json: SomeJson,
// No need to call JSON.stringify
create("todo", { title, json: { foo: "a", bar: false } });
const { rows } = useQuery(allTodos);
// Evolu automatically parses JSONs into typed objects.
if (rows[0]) console.log(rows[0];

Evolu's automatic JSON string parsing is based on heuristics (if it looks like a stringified JSON, try to parse it). That means that any string that is a stringified JSON will be parsed. Use the Evolu String schema for all string columns to prevent a situation where the user stores stringified JSON in a regular string column. Check how String1000 schema is made.