These are a set of experiments to compare different local-first libraries. The core of the app was heavily on @ryanflorence's Trellix with an added TO-DO list feature. Entities are created, reordered, renamed, and soft deleted.
- Load full entity dataset lazily. Working on a TO-DO list should not necessarily load boards because they're not directly related.
- No manual optimistic responses
- Instant interactions
- Cache datasets locally so the next load is instant
- Remote change subscription
- Cross-tab sync
- Schemaless but with TypeScript support
- Store offline cache in IndexedDB
- Infinite queries for searching larger datasets
- Network awareness to pause and resume sync
- Field-level conflict resolution: Multiple updates to the same row should succeed as long as they mutate different fields.
Implementation details
The app is a SPA PWA built with Solid and styled with solid-ui and TailwindCSS.
The backend is a PocketBase instance hosted on Railway. These are implementation details because they don't affect the client's local-first features. I should be able to replace PocketBase with a custom backend with any DB engine.
This is the "baseline" app. It uses query
with classic client + server, request + response setup with optimistic responses. It also integrates an IndexedDB persister to load queries from the cache on initial load.
- Familiar client + server, request + response model
@tanstack/query
is very good
- Manual optimistic responses on every interaction that would not scale to 100s of db entities.
- Loading states everywhere
- Completely fails when offline and changes interfere with each other. Optimistic responses are not true conflict resolution.
- No cross-client (tab or device) synchronization
- No remote change subscription
This is the same as the baseline app except it integrates normy
. Normy promises to do automatic cache updates by normalizing every response à la Apollo's normalized cache. It still requires manual optimistic responses and rollbacks.
- Same as baseline
- Normalized cache should make individual row updates simpler
- Same as baseline
- Normy does not support deleting an item from a list. (comment)
- Did not get normy to handle deletes
Uses SignalDB to intermediate interactions to the server. SignalDB syncs an entire subset of a db table that the user may have access to. When a change occurs to the client's copy, Signaldb provides a hook for you to implement storing/pushing those changes to the authoritative source. Signaldb provides a hook to subscribe to remote changes. Signaldb allows defining a local persister to show data immediately.
Update since v0.20.0: SignalDB added the SyncManager
. SyncManager
handles batching, retries, and last-write-wins replication.
- Loads entire dataset and operates on it locally
- Instant changes without optimistic mutations
- No loading states anywhere
- Cross-client (tab and device) synchronization
- Simple yet powerful query API
- Allows only updating changed rows after a change
- Replication retry. New in v0.20.0
- Batching. New in v0.20.0
- Pulls, integrates local changes, and pushes. New in v0.20.0
- SignalDB is pre-1.0
- Loads all datasets on startup. There is an
AutoFetchCollection
that loads until requested, but it's meant for loading a different dataset for each query. No built-in replication retryAdded in v0.20.0Not local-first out of the boxAdded in v0.20.0No way to mark changes as confirmed or notAdded in v0.20.0- No way to clear and reload a collection
- No way to unmount a collection after logout
- No transaction API to group inter-dependent cross-collection changes together
- Not aware of network connection. It should pause syncing if offline and resume when online
Uses Replicache based on their todo-row-versioning
example. Replicache is a proper local-first library. It applies changes to the local cache first and then sends just the update to the server. The server then responds with the confirmed patch. This example implements the row versioning strategy so only minimal patches are sent. The Client View Record strategy is genius but tricky.
This experiment does some things differently from the todo-row-versioning
example. It uses the Pocketbase real-time API instead of implementing a new poking mechanism. It stores the CVR in the API's session store using Bun's SQLite.
- Loads entire dataset and operates on it locally
- Instant changes without optimistic mutations
- No loading states anywhere
- Mutation made in transactions
- Built-in replication retry
- Local-first backed by IndexedDB
- Cross-client (tab and device) sync
- Server knows exactly what the client loaded so it can send minimal patches
- Aware of network connection. Pauses syncing if offline and resumes when online
- No Solid integration
- Not reactive.
subscribe
does not react to external data changes - Not open source, for now?
- Limited querying API based on an item's cache key prefix
- May be tricky to extend to hundreds of entities
Run bun install
at the root.
To start a development server:
cd <experiment directory>
bun dev
# or start the server and open the app in a new browser tab
bun dev -- --open
Run bun run build
in the specific experiment directory. You can preview the built app with bun start
.