Patterns and pitfalls discovered while tuning the SPA.
When to add an entry: after fixing a perf bug whose root cause was a pattern (not a single-site mistake). Date it (
YYYY-MM-DD), state the Learning + Action in 2–4 lines, and add the newest entry at the top of its block. Newest entries win when guidance conflicts. If a pattern fits inside an architecture doc (docs/architecture/*.md), put the canonical version there and add a short pointer entry here.
Learning: Adding one more await import() to initializeSPA (even for a 2 KB chunk that holds an idle event handler) dropped the deploy preview's Lighthouse mobile-throttled Performance score from ~90 to 65. Throttled mobile budgets a single extra critical-path round-trip generously. The earlier "Non-blocking Component Preloading" rule applies recursively — once one await is justified for an ordering reason (e.g. navigation-script), it is tempting to chain another on the same line. Don't.
Action: Default every new dynamic import in initializeSPA to import(...).then(use) (fire-and-forget). Only chain a second await if you can name the specific race it prevents and prove the handler must be attached before first paint. Idle handlers that the user can't trigger within ~500 ms (e.g. the easter-egg detector) do not qualify.
Learning: Search inputs triggering filtering operations directly on every keystroke can cause performance bottlenecks. Debouncing is a simple, effective optimization. Action: Always wrap search input handlers with a debounce function (e.g., 300ms) to reduce the frequency of filtering operations and improve application responsiveness.
Learning: awaiting every component import in the application initialization path (e.g., in initializeSPA) delays the Time to Interactive (TTI) because the router waits for all components to load before rendering.
Action: Separate truly critical shell components (like navigation-script) from supplemental ones (like Auth and Search). await only critical shell dependencies to avoid race conditions, but let supplemental ones load in parallel via non-blocking Promise.all().catch().
Learning: Firestore getDoc calls are not automatically deduped or cached in memory across components if not using a listener. Centralizing user data fetching in AuthService and listening for update events (like recipe-favorite-changed) significantly reduces redundant network requests.
Action: Always prefer a centralized data service with caching and event listeners for frequently accessed, user-specific data that changes based on UI actions.
See docs/architecture/services.md for the canonical pattern (store and return the in-flight promise inside the service so concurrent callers share one network request). Pointer kept here for discoverability.
Learning: npm install can generate significant noise in package-lock.json if local environment differs from CI/CD. Also, simple Map caches in SPAs can leak memory if unbounded.
Action: Revert package-lock.json if no dependencies added. Always implement LRU or size limits for in-memory caches in long-running applications.
Learning: Web Components often default to fetching their own data based on ID attributes. This causes N+1 fetch cascades in lists.
Action: Always implement a property setter (e.g., set data(val)) on list item components to allow parent lists to pass pre-fetched data directly, bypassing the internal fetch.
Learning: Static imports of page modules in the main entry point (src/app.js) defeat the purpose of lazy loading logic in PageManager. Vite bundles everything imported statically into the main chunk.
Action: Always use dynamic import() inside route handlers for page components to ensure they are split into separate chunks.