Local-First Architecture
Offline-first data management with real-time synchronization
The platform uses a local-first architecture built on SignalDB with IndexedDB persistence. All data interactions happen locally first, with bi-directional synchronization to the server via REST APIs.
Overview
- Immediate Responsiveness: All reads and writes target local IndexedDB-backed collections. UI updates are instant.
- Offline Functionality: Collections persist in IndexedDB across page reloads. When offline, users can browse cached data and make edits; changes are queued and pushed when connectivity returns.
- Automatic Synchronization: The
SyncManagerhandles incremental pull (viaupdatedAttimestamps) and push (local mutations). - Conflict Resolution: Server-side
baseRevisionchecking detects concurrent edits. A Conflict Resolution UI lets users choose "Accept Theirs" or "Keep Mine". - Per-Entity Sync State: An
EntitySyncStateServicederives per-entity status (synced,localChanges,conflict).
graph TB
subgraph "Client"
UI[Svelte Components] --> Collections[SignalDB Collections]
Collections --> IDB[IndexedDB Persistence]
SM[SmallstackSyncManager] --> Collections
SM --> API[REST API calls]
NS[NetworkStatusService] --> SM
ESS[EntitySyncStateService] --> SM
end
subgraph "Server"
API --> Auth[Better Auth]
Auth --> Endpoints[Generic CRUD Endpoints]
Endpoints --> MongoDB
endCore Services
SignalDbService
Located at packages/client/src/services/signal-db.service.svelte.ts. The central service for data management.
Key responsibilities:
- Creates and manages SignalDB collections with IndexedDB persistence adapters
- Configures the
SmallstackSyncManagerfor bi-directional sync - Tracks initial sync state per collection (
initiallySynced) - Tracks last sync timestamps per collection (
lastSyncedAt) - Manages sync conflicts and their resolution
- Handles configurable polling for opted-in collections (default 5s; drops to 30s heartbeat when Ably is active)
// Getting a collection (creates it on first access, starts sync)
const collection = signalDbService.getCollection<MyEntity>("tenant-abc-types");
// With real-time polling enabled
const collection = signalDbService.getCollection<MyEntity>("tenant-abc-types", { realtime: true });
// Wait for initial data to be available
await signalDbService.isInitiallySynced("tenant-abc-types");
// Adjust polling interval (e.g., reduce to 30s heartbeat while Ably is active)
signalDbService.setPollingInterval(30_000);
// Check whether a collection has been registered
signalDbService.hasCollection("tenant-abc-types"); // boolean
// Check whether a collection has realtime polling enabled
signalDbService.isRealtimeCollection("tenant-abc-types"); // boolean
// Manually trigger a pull sync for a collection
await signalDbService.refreshCollection("tenant-abc-types");
SmallstackSyncManager
Located at packages/client/src/services/smallstack-sync-manager.ts. A subclass of SignalDB's SyncManager that exposes pending change information:
// Get IDs of entities with un-pushed local changes
const pendingIds: Set<string> = syncManager.getPendingChangeIds("my-collection");
// Get detailed pending changes for a specific entity
const changes = syncManager.getPendingChanges("my-collection", "entity-123");
// Get total pending change count across all collections
const count: number = syncManager.getPendingChangeCount();
NetworkStatusService
Located at packages/client/src/services/network-status.service.svelte.ts. Reactive online/offline detection using browser APIs:
import { networkStatusService } from "@smallstack/client";
// Reactive boolean ($state)
networkStatusService.isOnline; // true or false
// Listen for status changes
const unsubscribe = networkStatusService.onStatusChange((isOnline) => {
console.log("Network status changed:", isOnline);
});
EntitySyncStateService
Located at packages/client/src/services/entity-sync-state.service.svelte.ts. Derives per-entity sync state:
import { entitySyncStateService } from "@smallstack/client";
// Returns: "synced" | "localChanges" | "conflict"
const state = entitySyncStateService.getState("entity-id", "collection-name");
// Total pending count
const count = entitySyncStateService.getTotalPendingCount();
Synchronization
How Pull Works
The SyncManager pull handler uses incremental sync:
- Initial sync (
lastFinishedSyncEndis null): Fetches all entities viaGET /api/...?updatedAt=0, returns{ items: [...] } - Subsequent syncs: Fetches only entities modified since the last sync via
GET /api/...?updatedAt=<timestamp>, categorizes intoadded/modified/removedbased onrevisionanddeletedAt - Persistence: The SyncManager itself uses an IndexedDB persistence adapter (
__signaldb_sync__), so sync metadata (snapshots, change tracking, sync operations) survive page reloads
How Push Works
Local mutations are captured by SignalDB's change tracking and pushed via:
- Inserts:
POST /api/...with the full entity - Updates:
PATCH /api/.../entityIdwith{ modifier, baseRevision } - Deletes:
DELETE /api/.../entityId
Conflict Detection
The server checks baseRevision against the current document revision:
- Client sends
{ modifier, baseRevision: N }in PATCH request - Server validates that the document's current
revision === N - If mismatch, server returns HTTP 409 with conflict details
- Client stores the conflict in
signalDbService.conflicts - User resolves via Conflict Resolution UI
Reconnect Behavior
When the network comes back online, networkStatusService.onStatusChange triggers syncManager.syncAll() to:
- Push any queued local changes
- Pull any server-side updates missed while offline
UI Components
OfflineBannerComponent
Fixed banner that appears when offline, auto-dismissing "Back online" notification on reconnect.
ConflictResolutionContainer / ConflictResolutionDialog
Renders active conflicts from signalDbService.conflicts as side-by-side cards with "Accept Theirs" / "Keep Mine" buttons.
EntitySyncBadge
Inline badge showing conflict or pending-changes status for a specific entity. Used in the entity editor.
PendingChangesCounter
Displays total pending change count. Placed in both app layouts.
StaleBadgeComponent
Shows per-collection staleness indicators based on lastSyncedAt timestamps.
Collection Naming & API Resolution
Collections are named using a convention that maps to API paths:
tenant-{projectId}-{typeId} → /api/projects/{projectId}/types/{typeId}/data
tenant-{projectId}-types → /api/projects/{projectId}/types
tenant-{projectId}-applications → /api/projects/{projectId}/applications
The describeCollection() utility from @smallstack/shared handles this mapping.
Type-Level Sync Configuration
A Type can opt out of server synchronization entirely via the syncType field. The field is configurable in the type editor.
| Value | Behavior |
|---|---|
"remote" |
Default. Entities sync to the server via the standard pull/push flow. |
"localOnly" |
Entities live only in IndexedDB. The SyncManager never POSTs them to the server. |
"localOnly" is intended for ephemeral or device-bound state — draft caches, UI preferences, scratch data. Choose it when the data should never leave the browser.
Data-loss caveat:
"localOnly"data lives only in the current browser profile. Clearing site data, switching browsers, or signing in on another device wipes it. There is no server-side recovery. If a type is later switched back to"remote", data that accumulated while it was"localOnly"is not retroactively pushed to the server and is permanently lost.
Offline Auth
When offline, server-side auth hooks (hooks.server.ts) catch network errors and allow the request to proceed with the locally cached session. Better Auth uses database-backed sessions with a 30-day expiry; the locally cached session remains valid offline up to that absolute expiry, not for 30 days from the moment the device went offline. A session already close to its expiry will still expire while offline.
Application-level switch: the services above keep data available offline. To keep the application UI itself working offline, an application must opt in via its Offline Capable setting, which eagerly preloads every widget chunk after the first page settles. It is off by default. See Offline capability under Applications.
Real-Time Updates
When an ABLY_API_KEY is configured server-side, the platform uses Ably-driven push notifications to trigger collection syncs instantly when data changes elsewhere — replacing the continuous 5s polling loop with a 30s safety-net heartbeat.
Without Ably (dev environments or missing key), polling remains at the default 5s interval.
Ably-driven sync behaviour
- Realtime collections (
{ realtime: true }) auto-sync silently within ~1.5 s of a remote change. - Manual collections (no
realtimeflag) show a badge on the refresh button so users decide when to reload. - Self-notifications are filtered: changes made in the current browser window never trigger a redundant re-fetch.
- If Ably is unavailable (e.g. token endpoint returns 503), the system continues on polling with no errors.
CollectionSyncNotificationService
Located at packages/client/src/services/collection-sync-notification.service.svelte.ts. Subscribes to the Ably project:{projectId}:sync channel and coordinates with SignalDbService.
// Subscribe when entering a project context (called from layout)
const connected = await syncNotificationService.subscribeToProject(projectId, userId);
if (connected) signalDbService.setPollingInterval(30_000); // reduce heartbeat while Ably is active
// Unsubscribe and restore polling on teardown
await syncNotificationService.unsubscribeFromProject();
signalDbService.setPollingInterval(5000);
// Check whether a manual collection has unseen remote changes
syncNotificationService.hasRemoteChanges("tenant-abc-contacts"); // boolean ($state)
// Clear the badge after a manual refresh
syncNotificationService.clearRemoteChanges("tenant-abc-contacts");
Collections opt in to the polling-based fallback as before:
signalDbService.getCollection("my-collection", { realtime: true });
// or
signalDbService.startRealtime("my-collection");
Best Practices
- Use
getCollection()lazily: Collections sync on first access. Don't pre-register collections you don't need yet. - Wait for initial sync: Use
await signalDbService.isInitiallySynced(name)before rendering data-dependent UI. - Soft deletes: Entities use
deletedAtfield. The sync system tracks deletions via this field. - baseRevision: Always include
baseRevisionin PATCH requests for conflict detection. - Avoid raw fetch for synced data: Use
collection.find()/collection.findOne()for reads. The sync layer handles server communication.
Debugging
In development, you can inspect:
- IndexedDB: Open Browser DevTools → Application → IndexedDB to see collections and the
__signaldb_sync__metadata stores - Console logs: Collection creation and sync operations are logged at
debuglevel - SignalDB DevTools: Uncomment the devtools import in
signal-db.service.svelte.ts