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

18 KiB
Raw Permalink Blame History

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 awaits 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

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

import {
  initNDKPage,
  getPubkey,
  queryCache,
  ndkFetchEvents,
  subscribe,
  // ... other imports
} from './js/init-ndk.mjs';

Step 2: Cache-first data loading function

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

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()

// BAD — blocks page for up to 15 seconds
async function main() {
  const events = await ndkFetchEvents({ kinds: [1], authors: [pubkey] });
  renderFeed(events);
}
// 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

// 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]);
}
// 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

// BAD — blocks detail page for up to 15 seconds
if (isEventMode) {
  const events = await ndkFetchEvents({ ids: [targetEventId], limit: 1 });
  renderSingleEvent(events[0]);
}
// 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.

// BAD — embedded note hydration blocks on relay, shows "Failed to fetch" on timeout
const events = await ndkFetchEventsFn(filter);
// 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.

// BAD — interaction counts blank for 15 seconds
const allEvents = await ndkFetchEvents(filters);
applyInteractions(allEvents);
// 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

// 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:

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:

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

// BAD — response blocked behind slow relay publishing
await publishDirectProofs();
await publishSpendingHistoryEvent(tx);
port.postMessage({ type: 'response', requestId, data: successPayload });
// 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.

// 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
    // ...
  });
}
// 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:

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:

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 firstawait 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