Files
client/docs/cache-first-page-patterns.md
T
2026-04-17 16:52:51 -04:00

552 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Cache-First Page Implementation Patterns
## Lessons Learned & Best Practices for client Pages
---
## The Core Problem
Every page in this app communicates with Nostr relays through a shared Web Worker via `init-ndk.mjs`. Relay responses are inherently slow and unreliable — they may take seconds, time out after 15s, or never arrive at all. If page initialization `await`s relay fetches, the user stares at a blank screen.
Meanwhile, NDK maintains an IndexedDB cache (via Dexie) that contains previously-seen events. This cache is local and fast (sub-100ms). **Pages must render from cache first, then hydrate from relays in the background.**
---
## The Three Data APIs
### 1. `queryCache(filters)` — Local IndexedDB only (FAST)
- **Timeout**: 5 seconds
- **Source**: Dexie/IndexedDB only, no network
- **Use for**: Initial page render, instant UI population
- **Import**: `import { queryCache } from './js/init-ndk.mjs'`
### 2. `ndkFetchEvents(filters)` — Relay fetch (SLOW)
- **Timeout**: 15 seconds
- **Source**: Nostr relays via NDK in the worker
- **Use for**: Background hydration after cache render
- **Import**: `import { ndkFetchEvents } from './js/init-ndk.mjs'`
- **WARNING**: Never `await` this in the critical render path if cache data exists
### 3. `subscribe(filters, opts)` — Live streaming (ONGOING)
- **No timeout**: Stays open, delivers events via `window 'ndkEvent'` events
- **Source**: Cache first (if `cacheUsage: 'CACHE_FIRST'`), then relays
- **Use for**: Live updates after initial render
- **Import**: `import { subscribe } from './js/init-ndk.mjs'`
---
## The Golden Rule
```
NEVER await a relay call (ndkFetchEvents) in the critical render path
if you can get data from cache first.
```
---
## Standard Page Loading Pattern
```mermaid
sequenceDiagram
participant Page
participant Cache as queryCache - IndexedDB
participant Relay as ndkFetchEvents - Relays
participant Sub as subscribe - Live
Page->>Cache: queryCache filters
Cache-->>Page: cached events - instant
Page->>Page: render UI from cache
Page->>Relay: void ndkFetchEvents filters
Note over Relay: fire-and-forget, no await
Relay-->>Page: relay events - eventually
Page->>Page: merge and re-render if newer
Page->>Sub: subscribe filters, CACHE_FIRST
Note over Sub: stays open for live updates
Sub-->>Page: ndkEvent window events
Page->>Page: upsert and re-render
```
---
## Implementation Template
### Step 1: Import both `queryCache` and `ndkFetchEvents`
```javascript
import {
initNDKPage,
getPubkey,
queryCache,
ndkFetchEvents,
subscribe,
// ... other imports
} from './js/init-ndk.mjs';
```
### Step 2: Cache-first data loading function
```javascript
async function loadPageData() {
const pubkey = await getPubkey();
// Phase 1: Cache (blocking — but fast)
let items = [];
try {
const cached = await queryCache({
kinds: [MY_KIND],
authors: [pubkey],
limit: 50
});
items = Array.isArray(cached) ? cached : [];
renderItems(items);
console.log('[my-page] Rendered from cache:', items.length);
} catch (err) {
console.warn('[my-page] Cache query failed:', err?.message);
}
// Phase 2: Relay hydration (non-blocking — fire and forget)
void ndkFetchEvents({
kinds: [MY_KIND],
authors: [pubkey],
limit: 50
}).then((relayEvents) => {
if (Array.isArray(relayEvents) && relayEvents.length > 0) {
// Merge with existing items, dedupe by id
const merged = mergeAndDedupe(items, relayEvents);
renderItems(merged);
console.log('[my-page] Hydrated from relays:', relayEvents.length);
}
}).catch((err) => {
console.warn('[my-page] Relay hydration failed:', err?.message);
});
// Phase 3: Live subscription for ongoing updates
subscribe(
{ kinds: [MY_KIND], authors: [pubkey] },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
}
```
### Step 3: Handle live events via ndkEvent listener
```javascript
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (!evt || evt.kind !== MY_KIND) return;
// Upsert into your data structure and re-render
upsertItem(evt);
renderItems(getAllItems());
});
```
---
## Common Anti-Patterns to Avoid
### Anti-Pattern 1: Awaiting relay fetch in main()
```javascript
// BAD — blocks page for up to 15 seconds
async function main() {
const events = await ndkFetchEvents({ kinds: [1], authors: [pubkey] });
renderFeed(events);
}
```
```javascript
// GOOD — render from cache, hydrate in background
async function main() {
const cached = await queryCache({ kinds: [1], authors: [pubkey] });
renderFeed(cached);
void ndkFetchEvents({ kinds: [1], authors: [pubkey] }).then(renderFeed);
}
```
### Anti-Pattern 2: Sequential relay fetches in a loop
```javascript
// BAD — each iteration blocks for up to 15 seconds
for (const item of items) {
const refs = await ndkFetchEvents({ ids: [item.refId] });
item.image = extractImage(refs[0]);
}
```
```javascript
// GOOD — try cache first, relay in background
for (const item of items) {
let refs = [];
try { refs = await queryCache({ ids: [item.refId] }); } catch (_) {}
const ref = refs.find(r => r.id === item.refId);
item.image = ref ? extractImage(ref) : '';
if (!ref) {
// Fire-and-forget relay lookup
void ndkFetchEvents({ ids: [item.refId] }).then((relayRefs) => {
const relayRef = relayRefs?.find(r => r.id === item.refId);
if (relayRef) {
item.image = extractImage(relayRef);
// Re-render if needed
}
}).catch(() => {});
}
}
```
### Anti-Pattern 3: Awaiting relay fetch for a single event by ID
```javascript
// BAD — blocks detail page for up to 15 seconds
if (isEventMode) {
const events = await ndkFetchEvents({ ids: [targetEventId], limit: 1 });
renderSingleEvent(events[0]);
}
```
```javascript
// GOOD — render from cache instantly, relay hydrates in background
if (isEventMode) {
let matched = null;
try {
const cached = await queryCache({ ids: [targetEventId], limit: 1 });
matched = Array.isArray(cached) ? cached.find(e => e?.id === targetEventId) : null;
} catch (_) {}
if (matched) renderSingleEvent(matched);
void ndkFetchEvents({ ids: [targetEventId], limit: 1 }).then(relayEvents => {
const relayMatch = Array.isArray(relayEvents)
? relayEvents.find(e => e?.id === targetEventId) : null;
if (relayMatch) renderSingleEvent(relayMatch);
}).catch(() => {});
}
```
### Anti-Pattern 4: Embedded content hydration using only relays
When a rendered post contains `nostr:nevent1...` references, the hydration code
fetches the referenced event to display an inline preview. This is a secondary
fetch triggered *after* the main content renders — easy to miss.
```javascript
// BAD — embedded note hydration blocks on relay, shows "Failed to fetch" on timeout
const events = await ndkFetchEventsFn(filter);
```
```javascript
// GOOD — try cache first, fall back to relay
let events = [];
if (queryCacheFn) {
try { events = await queryCacheFn(filter); } catch (_) {}
}
if (!events || events.length === 0) {
events = await ndkFetchEventsFn(filter);
}
```
### Anti-Pattern 5: Interaction data fetched only from relays
Social interaction queries — likes, reposts, zaps, comments — use `#e` tag
filters. These should also be cache-first so the interaction bar populates
instantly on page refresh.
```javascript
// BAD — interaction counts blank for 15 seconds
const allEvents = await ndkFetchEvents(filters);
applyInteractions(allEvents);
```
```javascript
// GOOD — show cached counts immediately, relay updates in background
let hadCache = false;
if (queryCacheFn) {
try {
const cached = await queryCacheFn(filters);
hadCache = applyInteractions(cached, 'cache');
} catch (_) {}
}
if (hadCache) {
void ndkFetchEvents(filters).then(relay => applyInteractions(relay, 'relays')).catch(() => {});
} else {
const relay = await ndkFetchEvents(filters);
applyInteractions(relay, 'relays');
}
```
### Anti-Pattern 6: Module loading that blocks on relays
```javascript
// BAD — configureMuteList + loadMuteList blocks if relay is slow
configureMuteList({ ndkFetchEvents, getPubkey });
await loadMuteList(); // blocks for 15s if no cache
// GOOD — pass queryCache so cache loads instantly, relay hydrates in background
configureMuteList({ ndkFetchEvents, queryCache, getPubkey });
await loadMuteList(); // returns instantly from cache, relays fire-and-forget
```
---
## Module Configuration Checklist
When configuring shared modules like `mute-list.mjs`, always pass **both** `queryCache` and `ndkFetchEvents`:
```javascript
configureMuteList({
ndkFetchEvents,
queryCache, // <-- REQUIRED for cache-first loading
publishEvent,
getPubkey,
nip44Encrypt: ...,
nip44Decrypt: ...
});
```
---
## Profile Resolution
The `profile-cache.mjs` module already implements cache-first patterns internally. When creating a profile cache instance, pass `queryCache`:
```javascript
const profileCache = createProfileCache({
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache, // <-- enables cache-first profile lookups
refreshIntervalMs: 20 * 60 * 1000
});
```
---
## Timeout Reference
| API | Timeout | Blocking? |
|-----|---------|-----------|
| `queryCache()` | 5s | Yes (but fast — local IndexedDB) |
| `ndkFetchEvents()` | 15s | **Only if you await it** |
| `subscribe()` | None | No (event-driven) |
| `publishEvent()` | 15s | Yes (must await for confirmation) |
---
## Worker Request Latency Pattern (Critical for Wallet Actions)
Some UI actions call worker RPCs via `sendWorkerRequest()` and have strict front-end timeouts (for example, 45s for `walletSendNutzap`, 90s for `walletPayInvoice`).
If a worker handler does both:
1. **critical state updates** (required for user-visible success), and
2. **slow relay-side writes** (proof republish, history events, cleanup),
then awaiting both in sequence can cause front-end timeout errors even when the payment actually succeeded.
### Anti-Pattern 7: Responding after non-critical relay writes
```javascript
// BAD — response blocked behind slow relay publishing
await publishDirectProofs();
await publishSpendingHistoryEvent(tx);
port.postMessage({ type: 'response', requestId, data: successPayload });
```
```javascript
// GOOD — respond after critical path, background the rest
const payload = getWalletBalancePayload();
port.postMessage({ type: 'response', requestId, data: successPayload });
broadcast({ type: 'walletBalanceUpdated', data: payload });
void (async () => {
try { await publishDirectProofs(); } catch (e) { console.warn(e); }
try { await publishSpendingHistoryEvent(tx); } catch (e) { console.warn(e); }
})();
```
### Rule of thumb for worker handlers
- **Await only critical path steps** needed to determine success/failure for the request.
- **Respond immediately** once local state is coherent.
- **Background non-critical relay writes** with `void` async blocks and warning logs.
- **Do not hide failures silently**: warn in logs so reconciliation/debug remains possible.
This is now the expected pattern for latency-sensitive wallet operations like `walletPayInvoice` and `walletSendNutzap`.
---
## Dependency Threading Checklist
When a page passes dependencies to a module, and that module passes them to a
sub-module, `queryCache` must be threaded through **every layer**. Missing it at
any hop means the inner module falls back to relay-only fetches.
```
page.html → post-interactions2.mjs → post-interactions.mjs
queryCache ✅ queryCache ✅ queryCacheFn ✅
```
**Rule**: If you add `queryCache` to a page's dependency object, grep for every
module in the chain that destructures and forwards those deps. Each one must
explicitly destructure and pass `queryCache` through.
```javascript
// post-interactions2.mjs — MUST destructure and forward queryCache
export function initPostCards(deps = {}) {
const { ndkFetchEvents, queryCache, /* ... */ } = deps;
const interactions = initInteractions({
ndkFetchEvents,
queryCache, // <-- MUST be forwarded here
// ...
});
}
```
```javascript
// post-interactions.mjs — MUST accept and store queryCache
let queryCacheFn = null;
export function initInteractions(ndkFunctions = {}) {
const { ndkFetchEvents, queryCache, /* ... */ } = ndkFunctions;
queryCacheFn = typeof queryCache === 'function' ? queryCache : null;
}
```
---
## Render & Decrypt Performance Lessons (from msg.html, v0.4.20v0.4.23)
These lessons were learned during the messaging page optimization cycle. They
apply to any page that does expensive async work (decryption, signing, heavy
DOM rendering) triggered by event-driven callbacks.
### Lesson 1: Measure first, then optimize
Add lightweight `performance.now()` instrumentation to every phase of your
page's critical path. Without timing data, you will guess wrong about where
the bottleneck is. In `msg.html`, we added a rolling perf report system
(`startMsgPerfReport` / `markMsgPerf` / `finalizeMsgPerfReport`) that logs
step-by-step timing to the console and exposes `window.__msgPerfReports` for
programmatic inspection. This immediately revealed that 63 seconds of wall
time was spent in eager decrypt — not in cache queries or DOM rendering.
### Lesson 2: Expensive async render functions need single-flight guards
If `renderThread()` (or any expensive async render function) can be called
from multiple event-driven paths — live subscription events, profile fetches,
gift-wrap processing, relay hydration callbacks — concurrent invocations will
pile up. Each one does the same expensive work (decrypt, DOM build), and all
but the last one get thrown away.
**Pattern**: Wrap the render function in a single-flight coalescing guard:
```javascript
let renderInFlight = null;
let renderQueued = false;
async function renderThread() {
if (renderInFlight) {
renderQueued = true;
return renderInFlight;
}
renderInFlight = (async () => {
do {
renderQueued = false;
await renderThreadInner();
} while (renderQueued);
})().finally(() => { renderInFlight = null; });
return renderInFlight;
}
```
This collapses N concurrent calls into at most 2 executions (current + one
queued rerun).
### Lesson 3: Deduplicate in-flight async work, not just completed results
Caching the *result* of an async operation (e.g., `event._decodedKind4`) is
not enough if multiple callers start the operation before the first one
finishes. You must also deduplicate the *in-flight promise* itself.
**Pattern**: Use a `Map` keyed by operation identity to store the active
promise:
```javascript
const inFlightDecrypts = new Map();
async function decryptEvent(event, counterparty) {
if (event._decoded) return event._decoded;
const key = event.id + ':' + counterparty;
if (inFlightDecrypts.has(key)) return inFlightDecrypts.get(key);
const promise = doActualDecrypt(event, counterparty)
.finally(() => inFlightDecrypts.delete(key));
inFlightDecrypts.set(key, promise);
return promise;
}
```
In `msg.html`, this reduced 290 decrypt calls (29 renders × 10 messages)
down to 10.
### Lesson 4: Status text should reflect progress, not just phase entry
Showing "Decrypting recent messages… 10" and then going silent for 60 seconds
is worse than showing nothing — it looks frozen. Update the status on every
iteration: "Decrypting recent messages… 3/10". This gives the user confidence
that work is happening.
### Lesson 5: Audit every call site that triggers re-render
Non-awaited render calls from places like `ensureProfileName()` and
`processKind4Event()` can fire dozens of times during background hydration.
Each one triggers a full render cycle. Treat render triggers like network
calls: audit them, guard them, and suppress them during batch operations
(e.g., using a `suppressUiRefresh` flag).
### Lesson 6: "Small" per-item latency explodes in serial loops
A 6-second decrypt call seems tolerable for one message. But 10 messages in a
serial loop = 60 seconds. And if that loop runs 29 times concurrently due to
a render storm, the user waits over a minute. Always consider the
multiplicative effect of per-item costs × loop iterations × concurrent
invocations.
### Lesson 7: Cache-first is necessary but not sufficient
Even after making `hydrateConversationHistory()` cache-first (rendering from
cache before relay hydration), the page still stalled because the *render
path itself* contained expensive blocking work (decrypt). Cache-first solves
the data-fetch bottleneck; you still need to address compute bottlenecks in
the render pipeline.
### Lesson 8: Keep perf tooling in production-safe form
Lightweight timing helpers that log to `console.groupCollapsed` and store
results in a bounded array are cheap enough to ship. They cost nothing when
nobody opens DevTools, and they save hours when debugging the next regression.
---
## Summary Rules
1. **Always import `queryCache`** alongside `ndkFetchEvents` in every page
2. **Render from cache first**`await queryCache()` is safe to block on (fast, local)
3. **Never await `ndkFetchEvents` in the render path** — use `void` for fire-and-forget
4. **Use `subscribe` with `cacheUsage: 'CACHE_FIRST'`** for live updates
5. **Pass `queryCache` to all module configurations** (mute-list, profile-cache, etc.)
6. **Thread `queryCache` through every dependency hop** — page → wrapper → inner module
7. **Apply cache-first to secondary fetches** — embedded notes, interaction data, not just main content
8. **Avoid sequential relay fetches in loops** — batch or use cache-first per item
9. **Always handle relay timeouts gracefully** — the cache result is good enough for initial render
10. **Guard expensive async render functions with single-flight coalescing** — prevent render storms
11. **Deduplicate in-flight async work** — cache the promise, not just the result
12. **Audit all re-render trigger sites** — suppress during batch operations
13. **Show incremental progress in status text** — update per-item, not per-phase
14. **For worker RPC handlers, respond before non-critical relay writes** — background republish/history/cleanup work with warning logs