Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.
Date: 2026-05-10
Status: Accepted
Deciders: Daniel Meza
Context and Problem Statement
A financial comparison platform has predictable caching needs: product lists and detail pages change infrequently (once per ingestion run, typically every 6–24 hours), but must serve quickly to many concurrent visitors. Redis is the conventional solution for application-level caching. However, adding Redis to the Phase 1 infrastructure introduces a Railway managed service with associated cost, an additional connection pool to manage, a cache invalidation protocol to implement, and a new operational component to monitor.
We need to decide: when is Redis necessary, and what do we use before that threshold?
Decision Drivers
- Low-ops principle: Per ADR-001, Phase 1 prioritizes operational simplicity. Every additional Railway service is an operational surface — another thing to monitor, patch, and pay for.
- ISR handles the most important caching: Next.js ISR (Incremental Static Regeneration) caches rendered HTML pages at the CDN (Content Delivery Network)/edge layer with configurable TTLs (Time to Live). Category pages and product detail pages are served from CDN without hitting the database or API, regardless of concurrent visitors. This is the most impactful caching layer for SEO traffic.
- Next.js fetch cache: In the App Router,
fetch()calls in Server Components are cached by default. API responses from the Backend API Service are cached per-request deduplicated within a single page render, and across renders withnext: { revalidate }semantics. This is an in-process, in-memory cache within the Next.js server. - Launch traffic scale: Expected launch traffic is 100–2,000 DAU (Daily Active Users). At this scale, PostgreSQL can handle product list queries directly. A PostgreSQL query on an indexed table of 200 products returns in < 10 ms. Redis would add ~1 ms round-trip but remove a ~10 ms DB query — not a meaningful improvement at this user count.
- Redis cost on Railway: Railway's managed Redis starts at ~$5–10/month on the Hobby plan. This is not a large cost, but it is an additional operational component that provides no measurable benefit at Phase 1 traffic.
- Kleppmann (DDIA, Ch. 1): "Scalability means having strategies for keeping performance good even when load increases." The question is not "can we add Redis?" but "what load increase triggers the need for Redis?" Defining this threshold explicitly makes the decision measurable and reversible.
Considered Options
- Redis from day one — Deploy Railway managed Redis at launch, implement full cache invalidation protocol, use Redis for product lists, comparison snapshots, and rate-limit counters.
- Next.js fetch cache + ISR only (no Redis, Phase 1) — Use Next.js built-in caching for all product data. Add Redis at a defined load threshold.
- PostgreSQL-only (no caching at any layer) — Read from the database on every request, no cache.
- In-process LRU (node-lru-cache) in the API service — Add an in-process cache to the Backend API Service, bypassing both Redis and Next.js cache.
Decision Outcome
Option 2: Next.js fetch cache + ISR as the Phase 1 caching strategy is adopted.
No Railway Redis service is deployed until the threshold defined below is reached.
Phase 1 caching layers (no Redis):
| Layer | Mechanism | What it caches | TTL |
|---|---|---|---|
| CDN/Edge (Railway) | Next.js ISR | Rendered HTML for category, filter, issuer, and product detail pages | 1 h (category), 4 h (product detail) |
| Next.js fetch cache | fetch() with next: { revalidate } in RSC (React Server Components) |
Backend API responses for SSR (Server-Side Rendering) pages (comparison pages, dashboard) | 15 min (comparison), 1 min (user data skipped) |
| In-process (API) | node-lru-cache (max 500 entries, LRU (Least Recently Used) eviction) |
Product list query results by vertical + filter hash | 1 h |
| Database | PostgreSQL B-tree indexes on vertical, issuer_id, status, updated_at |
N/A — query optimization | N/A |
The in-process LRU cache in the API service is a lightweight addition using lru-cache (no external dependency, runs in the Node.js process). It is explicitly designed to be replaced by Redis without interface changes: the Cache Layer component in the Product Comparison Service has a provider interface that supports both implementations.
interface CacheProvider {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttlSeconds: number): Promise<void>;
del(key: string): Promise<void>;
}
// Phase 1: InProcessLRUCache implements CacheProvider
// Phase 2: RedisCache implements CacheProviderCache invalidation in Phase 1:
- On successful ingestion write for a vertical: the ingestion service calls
POST /api/revalidatewith the affected vertical and product IDs. Next.js invalidates the ISR cache for those paths on-demand. The in-process LRU cache entry for that vertical is evicted by key. - In-process LRU entries have a 1-hour TTL as a safety net if the invalidation call fails (e.g., ingestion service network error).
Phase 2 Redis introduction threshold — introduce Redis when ANY of the following is true:
- p95 PostgreSQL query latency on product list queries exceeds 100 ms (measured from Railway metrics dashboard).
- Peak concurrent active sessions exceed 200 (measured from Next.js server memory usage on Railway).
- The Backend API Service requires horizontal scaling (multiple Railway instances) — in-process LRU caches are not shared across instances, making Redis necessary for consistency.
- Rate-limit counters need to be shared across multiple API instances (in-process counters do not work with horizontal scaling).
When Redis is introduced, only two changes are required:
- Provision Railway Redis and inject
REDIS_URLas an environment variable. - Swap
InProcessLRUCacheforRedisCachein the dependency injection configuration. No changes to callers.
Positive Consequences
- Lower operational complexity in Phase 1: No Redis connection pool, no Redis health checks, no Redis-specific observability, no Railway Redis service to provision.
- Cost savings: No additional Railway Redis bill during the validation/launch period.
- ISR is more effective than Redis for SEO (Search Engine Optimization) traffic: The most valuable caching for SEO traffic (category and product pages) happens at the Next.js/CDN layer, which is unrelated to Redis. ISR effectively makes the product comparison site static for the majority of anonymous visitors.
- Clear upgrade path: The
CacheProviderinterface makes swapping to Redis a configuration change, not a refactor. The threshold criteria make the decision measurable. - Kleppmann-aligned thinking: Adding infrastructure before the load justifies it is a form of premature optimization. "The right approach is to be agnostic about particular tools. Understand the tradeoffs, then choose the right tool for the job at the right time." (DDIA, Ch. 1)
Negative Consequences / Trade-offs
- In-process LRU is not shared across API instances: If the API service is horizontally scaled (multiple Railway instances), each instance has its own LRU cache. Cache invalidation calls must fan out to all instances — which is operationally complex. Mitigation: horizontal scaling of the API is the threshold trigger for Redis introduction (Condition 3 above). Before that point, a single API instance is sufficient.
- In-process memory pressure: The LRU cache consumes API process memory. With a 500-entry LRU and ~5 KB average product list payload, the maximum memory footprint is ~2.5 MB — negligible on any Railway instance size.
- ISR can serve stale content for up to TTL duration if on-demand revalidation fails: If the ingestion service fails to call
/api/revalidate, the ISR cache serves stale content until the TTL expires. Mitigation: the in-process LRU TTL acts as a safety net; the ingestion audit log records failed revalidation calls for operator review.
Pros and Cons of the Options
| Option | Ops Complexity | Cost | Effectiveness at Phase 1 Scale | Upgrade Path |
|---|---|---|---|---|
| Next.js fetch cache + ISR (chosen) | Low | Zero (no Redis) | High (ISR eliminates DB hits for SEO traffic) | Clear (CacheProvider interface) |
| Redis from day one | Medium | ~$10/month | High | N/A |
| No caching | Very low | Zero | Low at scale | N/A |
| In-process LRU only (no ISR) | Low | Zero | Medium (no edge caching) | Partial |
References
- Kleppmann, Designing Data-Intensive Applications, Ch. 1 (Scalability — describing load, performance), Ch. 3 (Storage and Retrieval — caching and indexing trade-offs)
- Next.js documentation: ISR, on-demand revalidation, fetch cache semantics
- Architecture reference §5.3 (Caching Strategy), §3 (Container Diagram — Redis note)