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:
- First paint: how quickly useful structure and content appear.
- Navigation latency: how long a route transition feels after intent.
- 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.
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
| Data | Current strategy | Why |
|---|---|---|
| Published prompt text | Next.js shared cache | Public, shared, changes through controlled admin transitions |
| Repeated read in one render | React request memoization | Eliminates duplicate work without cross-request staleness |
| Relationship membership | PostgreSQL | Private and permission-sensitive |
| Partner answers and reveal state | PostgreSQL | Stale data can become a privacy failure |
| Notifications | PostgreSQL with bounded indexed reads | User-specific and mutation-heavy |
| Queue state | Redis | Transport 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.
admin mutation
├── commit prompt lifecycle in PostgreSQL
└── POST http://web:3000/api/revalidate/prompts
└── invalidate published prompt cache tagsThe 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.
Change the evidence, not the ambition
These are teaching thresholds, not capacity promises. They show which decisions depend on scale.
- 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.
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:docsNext.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
- Identify the user-visible wait.
- Trace its server and browser phases.
- Remove unnecessary work.
- Parallelize independent required work.
- Cache only with an explicit invalidation owner.
- Re-measure the flow that motivated the change.
Next: queues and asynchronous work.