FAQ

Frequently asked questions about Evolu.

Questions

What is the SQLite database size limit?

The size limit depends on the storage quotas and eviction criteria of the browser or platform in use. For detailed information, refer to the Storage quotas and eviction criteria documentation.

Will my data stay on the device?

It depends on the platform, and a browser never promises it. Each Evolu instance tells what it knows for sure with evolu.devicePersistence:

  • Persisted: the database is the app's own file, as in React Native.
  • NotPersisted: the database is kept in memory, because the memoryOnly option asks for it or the browser offers no persistent storage. Data synced with a relay comes back, as on a new device, but changes that have not synced are lost when the tab hosting the database closes.
  • Unknown: the browser stores the database, but it may delete it.

Show the user a notice for NotPersisted, and never tell them their data is saved on the device unless it is Persisted. The examples show both.

How Evolu detects missing storage

Evolu's shared worker calls navigator.storage.getDirectory() once, before any database starts. If it fails, for any reason, every database of that worker is kept in memory until the worker ends. The check cannot tell private browsing from a storage failure, and it does not need to: memory keeps working, and a persistent database the browser cannot reach stays untouched.

BrowserPrivate browsingEvolu reports
Safari on macOS, iOS, and iPadOSNo OPFS: getDirectory() rejects with UnknownErrorNotPersisted
Other iOS and iPadOS browsers built on WebKitThe same as SafariNotPersisted
FirefoxNo OPFS since Firefox 109: getDirectory() rejects with SecurityErrorNotPersisted
Chrome, Edge, and other Chromium-based browsersOPFS is kept in memory and deleted when the incognito session ends, undetectablyUnknown

Why a browser may delete stored data

  • Safari deletes it after seven days without a visit. With tracking prevention on, the default, Safari deletes all data a site's scripts stored, including OPFS, after seven days of Safari use without the user interacting with the site. Web apps added to the Home Screen are exempt. See Full Third-Party Cookie Blocking and More.
  • Browsers delete it when disk space runs low. Chrome, Firefox, and Safari delete the least recently used sites' data first, unless the site's storage is persistent. See Storage quotas and eviction criteria.
  • Users can clear site data at any time.

Only changes that have not synced exist nowhere else, so an app that syncs with a relay loses at most those.

Persistent storage

A site whose storage is persistent keeps it when disk space runs low. After the first local change of a database the browser stores, Evolu asks the browser once per tab with navigator.storage.persist():

  • Chrome decides silently, granting it to sites the user installed, bookmarked, uses often, or allows to send notifications.
  • Firefox asks the user, so every tab asks until the user allows it.
  • Safari decides silently, granting it mainly to Home Screen web apps.

Evolu asks after the first change, not on page load, because only unsynced changes need it, and Firefox's prompt makes sense to the user only after they saved something. To ask at another moment, or never, pass your own requestPersistentStorage to createEvoluDeps:

import { constVoid } from "@evolu/common";
import { createEvoluDeps } from "@evolu/web";

const deps = createEvoluDeps({ requestPersistentStorage: constVoid });

A user who wants nothing kept on the device needs the app to offer the memoryOnly option.

How can I check the current database filesize?

Use exportDatabase method on Evolu instance.

const database = await evolu.exportDatabase();
const sizeInBytes = database.length;
console.log(`Database size: ${sizeInBytes} bytes`);

How to delete OPFS Sqlite in browser?

To clear the OPFS (Origin Private File System) SQLite database:

  1. Install the OPFS Explorer Chrome DevTools extension
  2. Disable JavaScript in your browser
  3. Reload the page
  4. Open DevTools and navigate to the OPFS Explorer tab
  5. Remove the SQLite database file
  6. Re-enable JavaScript
  7. Reload the page

Evolu uses Kysely, the type-safe SQL query builder for TypeScript.

Kysely is not an ORM. It does not have the concept of relations. Kysely is a query builder—it builds 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, and Evolu provides safer SQLite JSON helpers built on that recipe.

TL;DR: JSON type with subselects. With this combination, we can write efficient queries with nested relations. Evolu's SQLite JSON helpers automatically parse only Evolu-marked JSON payloads into typed objects, so regular strings are not mistakenly parsed as JSON.

How do I store device-specific settings that shouldn't sync?

Use a separate Evolu instance with transports: [] and _-prefixed tables for device-specific settings like UI preferences, onboarding state, or account management flow. The prefix keeps the tables local even if the instance later syncs other owners:

The following setup reuses the shared run and persisted appOwner from Create Evolu.

import { AppName, createEvolu, id } from "@evolu/common";

const PreferencesId = id("Preferences");
type PreferencesId = typeof PreferencesId.Output;

const DeviceSchema = {
  _preferences: {
    id: PreferencesId,
    // whatever
  },
};

// Local-only instance for device settings (no sync)
const deviceEvolu = await run.ok(
  createEvolu(DeviceSchema, {
    appName: AppName.orThrow("MyApp-Device"),
    transports: [], // No sync - stays local to device
    appOwner,
  }),
);

This approach gives us:

  • Type safety with the same Evolu APIs
  • Schema evolution for local settings
  • Reactive queries for local state
  • Complete separation from synced user data

How do I store owner-specific data that shouldn't sync?

Tables prefixed with underscores (_) are local-only within an existing Evolu instance—they're never synced.

Imagine editing a JSON-rich text document. Syncing the entire document on every keystroke would be inefficient. Instead, create an Evolu instance with the following schema and save drafts to its local table first. The documentId, title, and content below are values already validated with the corresponding schema types.

import {
  id,
  NonEmptyTrimmedString,
  NonEmptyTrimmedString1000,
  sqliteTrue,
} from "@evolu/common";

const DocumentId = id("Document");

const Schema = {
  // Regular synced table
  document: {
    id: DocumentId,
    title: NonEmptyTrimmedString1000,
    content: NonEmptyTrimmedString,
  },
  // Local-only table (underscore prefix)
  _documentDraft: {
    id: DocumentId,
    title: NonEmptyTrimmedString1000,
    content: NonEmptyTrimmedString,
  },
};

// Save draft locally on every keystroke (no sync)
evolu.upsert("_documentDraft", {
  id: documentId,
  title,
  content,
});

// When ready to sync (e.g., on blur, route change)
evolu.update("_documentDraft", { id: documentId, isDeleted: sqliteTrue });
evolu.upsert("document", { id: documentId, title, content });

Evolu batches mutations queued in the same microtask and applies them in a SQLite transaction. The draft deletion and document upsert above are therefore applied together. Local tables use the same mutation and reactive query APIs as synced tables.

Drafts in local tables also survive reloads and accidentally closed tabs, so keep anything a user would be sad to lose there. For example, when a tab loads a new version of your app whose Evolu workers changed, such as after an Evolu update, Evolu reloads the tabs still running the previous version.

How do I integrate with external systems that have their own IDs?

Use createIdFromString to convert external IDs into valid Evolu IDs. Using the schema and Evolu instance from Get started:

import { createIdFromString, NonEmptyTrimmedString100 } from "@evolu/common";

// Use the table's ID brand, matching TodoId from the getting-started schema.
const todoId = createIdFromString<"Todo">("external-todo-456");

evolu.upsert("todo", {
  id: todoId,
  title: NonEmptyTrimmedString100.orThrow("Task from external system"),
});

This ensures that multiple clients creating records with the same external identifier will generate the same Evolu ID.

Important: This transformation uses the first 16 bytes of SHA-256 hash of the string bytes, therefore it's not possible to recover the original external string. If you need to preserve the original external ID, store it in a separate column.

When the external IDs are UUIDs, use uuidToId instead. A UUID and an Evolu ID are both 16 bytes, so the conversion is reversible with idToUuid, and the original ID doesn't need its own column:

import { assertEqual, idToUuid, Uuid, uuidToId } from "@evolu/common";

const externalId = Uuid.orThrow("0190a6f4-8c3e-7b2a-9d41-5e6f7a8b9c0d");
const todoId = uuidToId<"Todo">(externalId);

// Later, when talking to the external system:
const uuid = idToUuid(todoId);
assertEqual(uuid, externalId);

Uuid accepts only the canonical lowercase form, so lowercase UUIDs from other sources before validating them. A time-based UUID, such as version 7, keeps its creation time in the Evolu ID.

How do I seed initial data like country lists or default categories?

Evolu previously had an automatic seeding feature that ran when an AppOwner was created, but it was removed because Evolu syncs all changes immediately. Every page visit would generate and sync data to the relay, creating unnecessary traffic. Automatic seeding opens a DoS vulnerability where bots or users could repeatedly trigger data creation by simply visiting the page.

Simple approach: Create initial data on explicit user action. For example:

import {
  createIdFromString,
  type Evolu,
  id,
  NonEmptyTrimmedString100,
} from "@evolu/common";

const CategorySchema = {
  category: { id: id("Category"), name: NonEmptyTrimmedString100 },
};

const seedDefaultData = (evolu: Evolu<typeof CategorySchema>) => {
  for (const [externalId, name] of [
    ["groceries", "Groceries"],
    ["utilities", "Utilities"],
    ["entertainment", "Entertainment"],
  ] as const) {
    evolu.upsert("category", {
      id: createIdFromString<"Category">(externalId),
      name: NonEmptyTrimmedString100.orThrow(name),
    });
  }
};

// Create an instance with CategorySchema and call seedDefaultData(evolu)
// from a setup button or onboarding step.

Note: Using createIdFromString ensures deterministic IDs, so if a user clicks the button on multiple devices (e.g., after restoring their account but before sync completes), the same data will be upserted rather than duplicated.

Alternative approach: Create Evolu without transports initially, verify the user through a backend service that knows whether it's their first login, then have the client insert data explicitly before enabling sync:

Using CategorySchema and seedDefaultData above, with the shared run and persisted appOwner from Create Evolu:

import { AppName, createEvolu } from "@evolu/common";

// Start without sync
const evolu = await run.ok(
  createEvolu(CategorySchema, {
    appName: AppName.orThrow("MyApp-Categories"),
    transports: [],
    appOwner,
  }),
);

// After backend verification for first-time user
if (isFirstLogin) {
  seedDefaultData(evolu);
}

// Enable sync on the same instance. Call unuseOwner() to release this use.
const unuseOwner = evolu.useOwner(appOwner, [
  { type: "WebSocket", url: "wss://free.evoluhq.com" },
]);

For reference data that rarely changes (like country or currency lists), consider whether it needs to be in the database at all—it might be better as a constant in your code or imported from a package.