KnowMore Real-Time Data Loading
Published on Nov 22, 2025
When a parent taps “yes” on their phone, the coach should see it on their screen within milliseconds. When a coach creates a new training event, every parent’s calendar should update instantly. That’s the baseline for KnowMore’s data layer.
This post covers how we built real-time data loading on top of PocketBase’s subscription system — the watch() utility, distributed team stores, and the reconnection strategy that keeps everything in sync.
Updated March 2026 to reflect current architecture (v0.6.0). Re-aligned May 2026 for the FSM collapse (Immich and dashboard preload moved out of UserData) and the route-group refactor — the “root layout” referenced below is now routes/(main)/+layout.svelte, the composition root for regular-user flows.
Table of Contents
- The watch() Utility
- Distributed Team Stores
- The userData Store
- Chat: Another watch() Consumer
- Reconnection
- Federation Data: The Other Loading Pattern
- Performance Characteristics
The watch() Utility
The core primitive is a function called watch() at $lib/utils/watch.svelte.ts. It combines PocketBase’s paginated getList() with real-time subscribe('*') into a single reactive interface.
const watcher = watch<EventsResponse>(pb, 'events', {
filter: pb.filter('teams ~ {:teamId} && startTime >= @yesterday', { teamId }),
sort: 'startTime',
pageSize: 20,
expand: 'teams,clubs,created_by'
});
await watcher.initialize();After initialize(), watcher.data is a reactive $state array that automatically stays in sync with the server. The interface exposes everything through Svelte 5 getters:
return {
get data() { return data; },
get loading() { return loading; },
get loadingMore() { return loadingMore; },
get hasMore() { return hasMore; },
get totalItems() { return totalItems; },
initialize, cleanup, refetch, loadMore, mutate, reconfigure
};Two-Step Initialization
The create-then-initialize pattern is intentional:
// Step 1: Create — sets up derived values, no network calls
this.eventsWatcher = watch<EventsResponse>(pb, 'events', { ... });
// Step 2: Initialize — fetches first page, starts subscription
await this.eventsWatcher.initialize();This lets stores wire up derived state and computed properties before data starts flowing. If watch() fetched immediately on creation, you’d get race conditions where derived values try to compute before their dependencies are ready.
Internally, initialize() does two things in sequence:
- Fetches page 1 via
pb.collection(name).getList()with the configured filter, sort, and expand - Subscribes via
pb.collection(name).subscribe('*', handler), passing the same filter, expand, and sort options for server-side subscription filtering
Real-Time Event Handling
When a real-time event arrives, watch() applies it directly to the local data array:
function handleRealtimeEvent(e: { action: string; record: T }) {
const recordToUse = e.record;
switch (e.action) {
case 'create':
if (!data.some(item => item.id === recordToUse.id)) {
data = mergeRecords(data, [recordToUse]);
}
break;
case 'update':
data = data.map(item =>
item.id === recordToUse.id ? recordToUse : item
);
break;
case 'delete':
data = data.filter(item => item.id !== e.record.id);
break;
}
}This works because the subscribe() call passes the same expand option to PocketBase that the initial getList() uses. Since PocketBase v0.21+, subscription payloads include expanded relations — so e.record already has the full team objects, sender info, or whatever else the watcher needs. No extra API call required.
The three operations each handle edge cases:
- Create deduplicates first — a real-time event might arrive for a record that was already fetched via pagination
- Update replaces in-place if the record exists, or adds it if it doesn’t (it may now match the filter after the update)
- Delete is straightforward — remove by ID
Maintaining Sort Order
New records need to be inserted in the right position. watch() has a mergeRecords() function that deduplicates and re-sorts:
function mergeRecords(existing: T[], newRecords: T[]): T[] {
const existingIds = new Set(existing.map(r => r.id));
const uniqueNew = newRecords.filter(r => !existingIds.has(r.id));
const merged = [...existing, ...uniqueNew];
return sort ? sortRecords(merged, sort) : merged;
}The sort function handles ascending/descending (via - prefix), strings, dates, and numbers — matching PocketBase’s sort syntax.
Pagination and Infinite Scroll
loadMore() fetches the next page and merges it into the existing data:
async function loadMore() {
if (!hasMore || loadingMore || loading) return;
loadingMore = true;
const result = await fetchPage(currentPage + 1);
data = mergeRecords(data, result.items);
currentPage++;
hasMore = currentPage < result.totalPages;
loadingMore = false;
}The mergeRecords call is important here too — a real-time event might have already inserted a record that also appears on the next page. Without deduplication, you’d get duplicates.
For infinite scroll, watch() optionally integrates with runed’s useIntersectionObserver. Set enableInfiniteScroll: true and bind the loadMoreTrigger to a sentinel element at the bottom of your list.
Runtime Reconfiguration
Sometimes filters need to change without destroying the watcher. reconfigure() handles this:
async function reconfigure(newOptions: Partial<WatchOptions<T>>) {
cleanup(); // Unsubscribe
data = []; // Reset state
currentPage = 1;
await fetchInitialData(); // Re-fetch with new options
await subscribe(); // Re-subscribe with new options
}Full teardown and rebuild. This is used when a user switches between player views or when a coach toggles between active and all events.
Distributed Team Stores
Rather than one monolithic store for all team data, KnowMore uses a TeamStoreManager that creates independent TeamStore instances:
Lazy Loading with Deduplication
async getOrCreateStore(teamId: string, team?, userPlayers?): Promise<TeamStore> {
if (this.stores.has(teamId)) {
return this.stores.get(teamId)!;
}
// Prevent duplicate concurrent fetches
if (this.loadingStores.has(teamId)) {
return this.loadingStores.get(teamId)!;
}
const loadingPromise = this.createStore(teamId, team, userPlayers);
this.loadingStores.set(teamId, loadingPromise);
try {
return await loadingPromise;
} finally {
this.loadingStores.delete(teamId);
}
}Two maps: stores holds completed stores, loadingStores holds in-flight promises. If two components request the same team simultaneously (e.g., navigating to a team page while the dashboard is still loading), they share the same promise instead of firing duplicate API calls.
What Each TeamStore Manages
A single TeamStore sets up multiple subscriptions:
| Data | Mechanism | Why |
|---|---|---|
| Events | watch() with expand | Needs full team/club objects for display |
| Responses | Manual subscription per event | Subscription payload is sufficient (no expand needed) |
| Players | watch() | Track player changes for the team roster with real-time updates |
| Past events | Manual pagination (no subscription) | Historical data rarely changes |
The events watcher uses PocketBase’s @yesterday datetime macro:
const filterStr = pb.filter('teams ~ {:teamId} && startTime >= @yesterday', {
teamId: this.team.id
});
this.eventsWatcher = watch<EventsResponse>(pb, 'events', {
filter: filterStr,
sort: 'startTime',
pageSize: 20,
expand: 'teams,clubs,created_by'
});Responses use a different strategy — they subscribe per-event and use the raw subscription payload directly:
handleResponseUpdate(e: { action: string; record: ResponsesResponse }) {
if (e.action === 'create') {
this.allResponses = [...this.allResponses, e.record];
} else if (e.action === 'update') {
this.allResponses = this.allResponses.map(r =>
r.id === e.record.id ? e.record : r
);
} else if (e.action === 'delete') {
this.allResponses = this.allResponses.filter(r => r.id !== e.record.id);
}
}This works the same way as watch() — the subscription payload has everything needed. Response records don’t use expanded relations, so the flat payload is sufficient.
Cross-Team Aggregation
The dashboard needs events from all teams in one sorted list:
getAllEvents(): EventsResponse[] {
const allEvents: EventsResponse[] = [];
for (const store of this.stores.values()) {
allEvents.push(...store.allEvents);
}
// Deduplicate — events can belong to multiple teams
const uniqueEvents = Array.from(
new Map(allEvents.map(e => [e.id, e])).values()
);
return uniqueEvents.sort((a, b) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
}Deduplication matters because a tournament event might belong to both U11 and U13 teams. Without the Map trick, it would show up twice in the calendar.
Store Persistence
Team stores live in a Map on the TeamStoreManager, which is set via createContext in the (main) group layout (the composition root for regular-user flows; the bare root routes/+layout.svelte is a minimal shell that no longer owns stores). They persist for the entire SPA session — navigate away from a team page and back, and the data is still there with the subscription still running. No loading spinner, no refetch.
The userData Store
The userData store orchestrates the entire initialization sequence. It uses watch() for two collections:
Players — all players where the current user is a guardian:
this.playersWatcher = watch<PlayersResponse>(pb, 'players', {
filter: pb.filter('guardians ~ {:userId}', { userId: user.id }),
pageSize: 50,
sort: 'first_name'
});Teams — all teams in the club (single-club architecture, so we load all teams — coaches and admins can see everything):
this.teamsWatcher = watch<TeamsResponse>(pb, 'teams', {
pageSize: 100,
sort: 'category,level'
});Derived state chains off these watchers reactively:
userTeams = $derived.by(() => {
return this.allTeams.filter(team =>
this.managedPlayers.some(player => team.players?.includes(player.id))
);
});When a new player is added to a team via real-time subscription, managedPlayers updates, which triggers userTeams to recompute, which triggers affiliatedTeams to recompute — all through $derived, no effects.
The Loading Sequence
Initialization runs through two FSM phases driven by a FiniteStateMachine instance with states uninitialized → loading_core → loading_dependent → ready. Each phase runs its work in parallel using Promise.allSettled:
Phase 1 — Core (loading_core): player watcher, teams watcher, and club/role lookup all start simultaneously. (Immich now boots in parallel as a separate task in the composition root, not inside UserData — see note below.)
Phase 2 — Dependent (loading_dependent): responses watcher, team stores via TeamStoreManager, users cache — items that need the core data to be available first.
await this.loadCoreData(user);
this.loadingFSM.send('core_complete');
await this.loadDependentData();
this.loadingFSM.send('dependent_complete');May 2026 update. UserData previously had a third FSM phase, loading_extras, which ran the Immich proxy probe and the dashboard team-photo preload. That phase was deleted: Immich boot now fires as a parallel task from the (main) composition root (after await userData), so UserData no longer imports from features/* or services/*. The composition root has a single shape now — await userData → fire all features in parallel — with no sync/async split.
Team stores are initialized in Phase 2 with Promise.allSettled so one failing team doesn’t block the rest:
const results = await Promise.allSettled(
uniqueTeams.map(async (team) => {
await this.teamStoreManager.getOrCreateStore(team.id, team, this.managedPlayers);
})
);Chat: Another watch() Consumer
The chat system follows the same patterns with one twist. Messages use watch():
this.messagesWatcher = watch<ChatMessageWithExpand>(pb, 'chat_messages', {
filter: pb.filter('team = {:teamId}', { teamId: this.teamId }),
expand: 'sender,reply_to,reply_to.sender',
sort: '-created',
pageSize: 15
});But reactions use a separate Map-based cache with manual subscriptions:
private async subscribeToReactions() {
this.reactionsSubscription = await pb
.collection('chat_reactions')
.subscribe<ChatReactionsResponse>('*', (e) => {
const existing = this.reactionsCache.get(e.record.message) || [];
if (e.action === 'create') {
this.reactionsCache = new Map(this.reactionsCache).set(
e.record.message,
[...existing, e.record]
);
} else if (e.action === 'delete') {
this.reactionsCache = new Map(this.reactionsCache).set(
e.record.message,
existing.filter(r => r.id !== e.record.id)
);
}
});
}The new Map(this.reactionsCache).set(...) pattern creates a new Map on every update to trigger Svelte reactivity. This is a deliberate choice — reactions update frequently and the clone-map pattern gives explicit control over when reactivity fires.
Like TeamStoreManager, there’s a ChatStoreManager that creates one ChatStore per team, lazy-loaded and persistent.
Reconnection
Mobile phones lose connections constantly — switching between WiFi and cellular, going through tunnels, phone sleeping. The reconnection strategy needs to be bulletproof.
PocketBase fires a PB_CONNECT event when the WebSocket reconnects. The (main) group layout (the composition root) subscribes to it:
pb.realtime.subscribe('PB_CONNECT', async () => {
if (!hasConnectedOnce) {
hasConnectedOnce = true;
return; // Skip on initial connection
}
isReconnecting = true;
await Promise.race([
(async () => {
await userData.refetch();
await notificationsStore.refetch();
await chatUnread.refetch();
await teamStoreManager.refetchAll();
await chatStoreManager.refetchAll();
})(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Refetch timeout')), 15000)
)
]);
isReconnecting = false;
});Key points:
- Skips initial connection — the first
PB_CONNECTis not a reconnection - Sequential store refetch — userData first (it holds the user context everything else depends on), then notifications, chat unread counts, team stores, and chat stores
- 15-second timeout — prevents hanging if the server is unreachable
- Full-screen overlay with a spinner shown during reconnection so the user knows something is happening
What refetch() does in each store:
watch()instances — reset to page 1, re-fetch data. Does NOT re-subscribe — PocketBase handles subscription re-establishment automatically on reconnectTeamStoreManager.refetchAll()— callsrefetch()on all cached team stores in parallel viaPromise.all- Chat stores — re-fetch messages and re-subscribe to reactions (reactions use manual subscriptions that need explicit re-establishment)
The gap between disconnect and reconnect is covered: any events missed during the outage are captured by the refetch(), and going forward the re-established subscriptions pick up new events.
Federation Data: The Other Loading Pattern
Not all data in KnowMore goes through watch(). The federation integration — standings, schedules, match details from the Swiss football federation — uses a completely different approach.
Federation data comes from an external scraper API, not from PocketBase collections. There are no real-time subscriptions to maintain, no filter-based updates to handle. The data is fetched on demand when a user navigates to a federation page.
Instead of watch(), federation pages use direct {#await} blocks in the template:
{#key forceRefresh}
{#await federation.getStandings(clubId, teamId, { season: CURRENT_SEASON })}
{@render skeletonTable(true)}
{:then standings}
<!-- render league table -->
{:catch error}
<Alert color="red">{error?.message ?? 'Failed to load standings'}</Alert>
{/await}
{/key}The FederationStore handles caching and deduplication:
SvelteMapcaches — results, standings, schedules, and match details are cached byclubId:teamId:seasonkeys. Navigate away and back, and the data is served from cache instantly.pendingSet — tracks in-flight requests to prevent duplicate API calls when multiple components or reactive evaluations trigger the same fetch concurrently.{#key forceRefresh}— wraps the{#await}block so incrementing the counter destroys and recreates it, triggering a fresh fetch for retry.
This two-pattern approach — watch() for PocketBase collections with real-time subscriptions, {#await} for external APIs with cache — keeps each data source using the pattern that fits it best. The federation store doesn’t need $effect, loading flags, or error state — Svelte’s template-level {#await} handles all three states (pending, resolved, rejected) declaratively.
Performance Characteristics
Network Efficiency
The initialization sequence makes 3-4 API calls via watch() watchers (players, teams, plus one per affiliated team’s events). Responses are loaded per-event but batched. The centralized user cache (users.svelte.ts) loads all user objects for affiliated teams in one batch, reducing per-component lookups from individual API calls to cache hits.
After initialization, the only API calls are:
- Real-time subscription events applied directly to local state (no extra API calls)
loadMore()pagination when the user scrolls- Manual operations (creating events, sending responses)
Memory
Team stores persist for the session but only load events from yesterday forward. Past events are loaded on demand via separate pagination. The user cache is scoped to affiliated teams only — a coach sees users from their teams, not the entire club.
Cleanup on logout destroys all stores, unsubscribes all watchers, and clears all caches.
Reactivity Without Effects
The entire data layer uses zero $effect calls. Reactivity flows through:
$stateinwatch()for the data array$derivedin stores for computed values (userTeams, coachedTeams, etc.)- Getters on the
watch()return object for fine-grained reactivity - Explicit method calls (
initialize(),refetch(),loadMore()) for side effects
This avoids the common Svelte 5 pitfall of cascading effects triggering unexpected re-renders.
This is the third post in the KnowMore series. For the architecture overview, see Building KnowMore - Architecture of a Privacy-First Sports Calendar. For the WebAuthn implementation, see Passwordless Auth with PocketBase.