Skip to content

Performance and caching

EverDuet optimizes perceived user flow before abstract throughput. The prompt page should feel immediate on first arrival and especially after navigating away and back.

Start with the shape of waiting

There are three different performance problems:

  1. First paint: how quickly useful structure and content appear.
  2. Navigation latency: how long a route transition feels after intent.
  3. Server throughput: how much concurrent work the system can complete.

Solving one does not guarantee the others. A large client cache might make repeat navigation fast while hurting first-load JavaScript. More database connections might increase throughput until they exhaust PostgreSQL.

The prompt route strategy

The web app combines:

  • server-rendered data for the first useful response;
  • route loading UI for unavoidable waits;
  • prefetch on likely prompt and navigation intent;
  • cache warming for shared published prompt content;
  • React request memoization for duplicate reads within one render;
  • concurrent independent database reads;
  • explicit cache revalidation after admin mutations.
ts
const [dailyPrompt, promptPage, relationship] = await Promise.all([
  getDailyPromptForUser(userId),
  getPublishedPromptPage(page),
  getActiveRelationship(userId),
])

Concurrency helps only when every result is required. The relationship path skips invitation queries once it knows the user is already connected, avoiding work rather than merely parallelizing it.

Cache by privacy and invalidation cost

DataCurrent strategyWhy
Published prompt textNext.js shared cachePublic, shared, changes through controlled admin transitions
Repeated read in one renderReact request memoizationEliminates duplicate work without cross-request staleness
Relationship membershipPostgreSQLPrivate and permission-sensitive
Partner answers and reveal statePostgreSQLStale data can become a privacy failure
NotificationsPostgreSQL with bounded indexed readsUser-specific and mutation-heavy
Queue stateRedisTransport state, never product authority

The useful question is not “can this be cached?” It is “who owns invalidation, and what happens when invalidation is late?”

Cross-app invalidation

Admin publishing changes the web app's cached prompt content. The admin app calls a private web revalidation endpoint after the database transition.

text
admin mutation
  ├── commit prompt lifecycle in PostgreSQL
  └── POST http://web:3000/api/revalidate/prompts
          └── invalidate published prompt cache tags

The database transition remains successful if revalidation needs a retry; stale public prompt copy is less harmful than rolling back durable curation after commit.

Database query design for now

Indexes match current access paths:

  • active membership by user;
  • prompt lifecycle plus schedule/publish timestamps;
  • response by relationship and prompt;
  • notification by user, read state, and creation time;
  • daily assignment by relationship and UTC day.

Offset pagination remains appropriate for a small prompt library because it supports direct page navigation and is easy to reason about. Cursor pagination becomes worthwhile when the collection reaches thousands and measured query time grows.

Scale simulator

Change the evidence, not the ambition

These are teaching thresholds, not capacity promises. They show which decisions depend on scale.

What changes at this shape?
  • Keep the simple in-process limiter; there is only one authority to coordinate.
  • Keep offset pagination because direct page navigation is useful and the collection is small.
  • Do not add replicas or caches without latency, connection, or throughput evidence.

Build performance is system performance

The original build could exceed a small host's memory even though each individual task fit. The release pipeline now runs static analysis and production builds sequentially.

dockerfile
RUN bun run coolify:verify \
    && bun run --cwd apps/web build:validated \
    && bun run --cwd apps/admin build:validated \
    && bun run build:worker \
    && bun run build:docs

Next.js type generation and TypeScript validation run before next build, avoiding overlapping compiler and bundler memory peaks while still failing closed on errors.

Performance anti-patterns avoided

  • adding Redis caching to every query before measuring;
  • hiding all waits behind blank screens;
  • prefetching private, expensive routes indiscriminately;
  • increasing every replica's database pool independently;
  • moving mutations to a queue just to make the HTTP response look faster;
  • running all builds in parallel on a memory-constrained builder.

A practical loop

  1. Identify the user-visible wait.
  2. Trace its server and browser phases.
  3. Remove unnecessary work.
  4. Parallelize independent required work.
  5. Cache only with an explicit invalidation owner.
  6. Re-measure the flow that motivated the change.

Next: queues and asynchronous work.

Built as a living architecture notebook for a personal project.