552 lines
18 KiB
Markdown
552 lines
18 KiB
Markdown
# 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.20–v0.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
|