January 22, 2026
The Three Levels of Speed: Architecting a Local-First Card Catalog
Built with Next.js, TypeScript, tRPC, Zod, Prisma, IndexedDB, Zustand, Cloudflare R2, Sharp, GitHub Actions
I engineered a Pokémon TCG catalog that delivers 20,000 cards with daily price updates in a 400KB payload, loads instantly on repeat visits, and costs $12/month total to operate at any scale. Traditional approaches using pricing APIs and backend infrastructure cost $119-188/month minimum and still suffer from request waterfalls and multi-second load times. I inverted the entire model.
The Architectural Decision
Most web apps follow the same pattern: user requests data → backend queries database → response streams back → UI renders. This works until you're serving 20,000 items with images to users who expect native app responsiveness. The standard approach would be pagination with intersection observers, but my stress testing revealed the fatal flaw: request waterfalls that left users staring at blank screens for 3+ seconds as they scrolled.
I made a counterintuitive bet: ship everything at once, optimize aggressively, and use the browser as the database. This required rethinking the entire data pipeline, but the tradeoff was clear: one 350KB download via CDN versus dozens of paginated requests. With background prefetching on the homepage, navigating to /cards or /dashboard becomes instant—the data is already there.
Cost Optimization & Scale Economics
Here's what matters for a business: this architecture costs $12/month total whether I have 100 users or 100,000 users. That includes pricing data from a third-party API ($10/month), Neon Postgres, Cloudflare R2 storage ($2/month combined), and Vercel hosting. No scaling costs, no server fleet, no backend monitoring.
Cost comparison for equivalent functionality:
- Traditional approach: Competitor API with price history ($99/month) + image optimization ($20-89/month for Vercel Pro or Cloudinary) = $119-188/month minimum
- My approach: Pricing API ($10/month) + infrastructure ($2/month) = $12/month at any scale
The key difference: traditional approaches serve data through backends that scale with traffic. My architecture serves preprocessed, static assets via CDN—infrastructure costs stay flat regardless of user count.
Performance Engineering: The Three Levels
Level 1: Actual Speed — Make It Small, Deliver It Fast
The Data Pipeline: The card catalog started as an 8MB JSON file. Unacceptable for mobile users on 4G. I built a preprocessing pipeline that runs weekly via GitHub Actions:
- Normalization: Moved redundant strings (card types, rarities, set names) into lookup tables referenced by index. Result: 8MB → 3.5MB (56% reduction)
- Brotli compression: Applied server-side compression with proper headers for automatic client decompression. Result: 3.5MB → 350KB (90% reduction)
- CDN strategy: Distributed via Cloudflare's global network with proper cache headers
Final payload: 350KB for 20,000 cards = 17.5 bytes per card. This fits comfortably in a background download that completes before users navigate to the catalog page.
The Image Problem: 14GB of PNG images presented a different challenge. Vercel's image optimization would have cost $30/month and I was burning through the 5,000 included monthly credits serving just a few thousand variants. I built my own pipeline:
- Downloaded original PNGs from R2 storage
- Generated 4 responsive variants per image using Sharp
- Converted to AVIF format with aggressive compression
- Built custom Next.js loader to serve correct variant based on device
Result: Images dropped from 800KB PNGs to 55KB AVIFs (93% reduction). This replicated paid services like Imgix or Cloudinary at zero cost through full ownership of the optimization pipeline.
Client-side optimization with IndexedDB persistence: After the initial download, data persists locally. Returning users hit the network zero times for the catalog. A versioning system with checksummed pointer files ensures data freshness without re-downloading unchanged content. Card and price data live in separate versioned payloads (350KB + 50KB). Cards release seasonally, prices update daily—separating them prevents daily re-downloads of the full 350KB catalog just to fetch fresh prices.
Search and filter performance: With 20,000 cards in memory, naive array filtering for card parameters would block the main thread and cause dropped frames. I initially considered Web Workers to offload the computation, but found a simpler solution: precompute filter maps during store hydration (Artists → Card IDs, Types → Card IDs, etc.). Instead of filtering arrays on every keystroke, the app intersects precomputed maps. Performance went from $O(n)$ to $O(1)$ lookups - search feels instant even on lower-end devices.
Level 2: Perceived Speed — Make It Feel Instant
Performance engineering isn't just about measuring milliseconds, it's about psychology. I eliminated loading states almost entirely through architectural choices:
Server-side rendering strategy: Nearly every page ships as pre-rendered HTML, not React component bundles. Next.js SSG converts components into minimal HTML files that render immediately, then hydrates interactivity progressively. Card pages use ISR (Incremental Static Regeneration) to generate on-demand and cache globally.
Loading skeletons replace spinners: Reserved layout space with visual placeholders creates the perception of instant response even during network requests. The card grid renders immediately with skeleton cards that populate as images stream in—no layout shift, no blank screens.
Progressive enhancement: The portfolio chart calculation (compute-heavy aggregation of daily prices across owned cards) runs asynchronously. The page shell renders immediately, charts populate when ready without blocking initial render.
Level 3: Predictive Speed — Eliminate Waiting Through Anticipation
The fastest request is the one you never make because you predicted it.
Background prefetching: The 350KB card payload and 50KB price payload downloads on homepage load, not when users navigate to the catalog. This converts what would be a 500ms-1s wait into 0ms perceived load time. The architecture assumes most users hit the homepage first (cardledger.io), so by the time they click to /cards or /dashboard, the data is already cached locally. A single-request architecture via CDN beats paginated waterfalls every time.
Optimistic UI updates: When users add cards to collections, the UI updates instantly assuming success. The mutation syncs to Postgres in the background with rollback on failure; data integrity maintained, perceived latency eliminated.
Smart preloading: Next.js Link components prefetch destinations when they enter the viewport. The first few images per set get priority loading tags to optimize Largest Contentful Paint metrics.
Self-Directed Architecture: Extending Local-First to Authenticated Data
The catalog proved local-first works for global, read-only data. User collections presented a harder problem: personalized, mutable data that syncs across devices while feeling instant.
The problem: The dashboard suffered from a 3.3-second Time-to-Interactive delay caused by a sequential dependency chain. The application had to await the tRPC collection fetch before it could even begin the compute-heavy portfolio history calculation (aggregating daily prices for owned cards since acquisition). This effectively blocked the entire page render until both the network request and the subsequent data aggregation finished.
The solution: Same IndexedDB + Zustand pattern, with a critical insight: separate fast data from slow data. I implemented stale-while-revalidate strategy:
- Dashboard immediately renders cached collection from IndexedDB (0ms network)
- Background process verifies data validity with server
- UI updates seamlessly only if cache is stale
- All mutations update UI optimistically, sync to Postgres with rollback on failure
Impact on infrastructure costs:
- Database queries reduced by 60-90% for returning users
- Casual user checking dashboard daily: 30 Prisma queries/month → 3-5 queries (only when mutations invalidate cache)
- Read-only sessions: zero database queries after initial cache
- Dashboard perceived load time: 3.3s → effectively 0ms
Acknowledged tradeoffs: The current implementation uses last-write-wins strategy. This handles 99% of usage (single device, sequential edits) but doesn't handle true simultaneous edits across devices. For production systems with active multi-device usage, I'd implement version-based conflict detection or WebSocket real-time sync. I didn't optimize for the 1% case when the 99% case needed solving first.
Product Engineering: Technical Choices Driven by User Experience
Every architectural decision traced back to user expectations. Card collectors expect instant visual feedback—the appeal of collectibles is their imagery. They expect to scroll endlessly through galleries without pagination breaking their flow. They expect their collection dashboards to feel as fast as the public catalog.
I monitored DevTools Network and Performance tabs obsessively, measuring every bottleneck. The best optimizations came from measuring operations and comparing visual effects, not by guessing. Showing stale data for 200ms feels instantaneous, showing a loading spinner for 200ms feels broken.
Results
- Performance: 0ms perceived load times for /cards and /dashboard after homepage visit, ~300ms initial homepage load
- Scale: Architecture decouples read-traffic from compute. Because the app serves static data via CDN, it supports virtually unlimited concurrent users with zero additional infrastructure cost.
- Data freshness: Daily price updates for 20,000+ items with zero user-facing latency
- User experience: Zero loading states after first visit, offline-capable catalog, instant collection management
Ultimately, this architecture wasn't designed to save money—it was designed to eliminate waiting. I wanted the app to feel 'native,' where every interaction is immediate.
By accepting the constraint that card data is relatively static, I was able to move the bottleneck from the network to the client. The result is a user experience that feels instant, and as a welcome side effect, operates for 10-16x less than competitors charging ~$99/mo for similar APIs.