January 22, 2026 | Last Updated: February 27, 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, React Virtuoso, uFuzzy

I engineered a large-scale trading card catalog that delivers 21,000+ cards with daily price updates in a 400KB combined 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 21,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

The three levels represent a hierarchy of speed problems, each requiring a different class of solution: making data small and fast to deliver, making interactions feel instant through your own engineering, and eliminating waiting entirely through prediction.

Level 1: Actual Speed — Make It Small, Deliver It Fast

Level 1 is about the data pipeline: how you get 21,000+ cards and their prices into the browser as fast as possible. These are solved problems with measurable outcomes.

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:

  1. Normalization: Moved redundant strings (card types, rarities, set names) into lookup tables referenced by index. Result: 8MB → 3.5MB (56% reduction). Beyond network savings, this halved the JSON.parse() blocking time and saved ~20MB of JS heap memory, ensuring the main thread remained unblocked on low-end mobile devices.
  2. Brotli compression: Applied server-side compression with proper headers for automatic client decompression. Result: 3.5MB → 350KB (90% reduction)
  3. CDN strategy: Distributed via Cloudflare's global network with proper cache headers

Final payload: 350KB for 21,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:

  1. Downloaded original PNGs from R2 storage
  2. Generated 4 responsive variants per image using Sharp
  3. Converted to AVIF format with aggressive compression
  4. 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 persistence with IndexedDB: 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.

Level 2: Interaction Speed — Make It Feel Instant

Level 2 is where the browser becomes the database and the engineering challenge shifts from delivery to runtime performance. With 21,000+ cards already in memory, the question becomes: how do you operate on that data without ever making the UI stutter?

Unlike Level 1, these solutions weren't handed to me by the framework. Each one came from profiling, discovering a specific bottleneck, and engineering a targeted fix.

Search & Filter: The Inverted Index: With 21,000+ cards in memory, naïve filtering initially blocked the main thread. I refactored this into a custom cardinality-sorted Inverted Index that intersects pre-computed Sets (Rarity, Artist, Set) to isolate candidates instantly. This optimization reduced pure filtering latency to a measured average of 0.3ms.

To optimize text search, I initially used a cascading strategy that instantiated new fuzzy searchers on filtered subsets to keep query times manageable (30-50ms → 5-10ms). However, this added complexity and memory overhead. I replaced this entire subsystem with uFuzzy, a micro-library that allows a single global instance to search the entire 21,000+ card dataset in <2ms. This eliminated the need for dynamic scoping entirely, reducing code complexity while ensuring the UI thread never blocks, even on low-end mobile devices.

Consistent Sorting with _index: Because Set intersections and fuzzy searches return results in a scrambled order, I needed a way to apply a deterministic sort to any filtered subset without re-calculating expensive date logic or set tie-breakers on the client. By stamping each card with its original array _index during hydration, I can run a fast integer sort to instantly restore the Release Date grouping in sub-millisecond time. Even with multiple filters active, the sort remains stable and fast.

JIT Rendering — Eliminating the Final Bottleneck: Profiling the full "search-to-display" pipeline revealed a hidden challenge: building 21,000+ complex card objects (merging data with daily prices) would stutter the UI for 20-30ms even after search returned instantly. It was only by wrapping the pipeline in performance.now() that I discovered this bottleneck—a discovery that wouldn't have come from guessing.

I solved this with JIT Denormalization. By leveraging the itemContent callback in the react-virtuoso grid and tuning the buffer with increaseViewportBy, the application now only performs data merging and object construction for the items actually needed for display—typically fewer than 60 cards at a time. The result: total time to display results matches the efficiency of the underlying filters (sub-ms overhead), and the UI remains fluid because the heavy work is deferred until a card actually enters the viewport.

Optimistic UI: When users add cards to collections, the UI updates instantly assuming success. The mutation syncs to Postgres in the background via tRPC with automatic rollback on failure—data integrity maintained, interaction latency eliminated. This was a deliberate engineering choice, not a framework default: Zustand manages the optimistic state layer with explicit rollback logic to handle the failure case cleanly.

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 download 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.

Stale-while-revalidate for authenticated data: The catalog proved local-first works for global, read-only data. The dashboard presented a harder problem: the app had to await the tRPC collection fetch before beginning the compute-heavy portfolio history calculation, causing a 3.3-second Time-to-Interactive delay. I implemented stale-while-revalidate to break the dependency chain:

  1. Dashboard immediately renders cached collection from IndexedDB (0ms network)
  2. Background process verifies data validity with server
  3. UI updates seamlessly only if cache is stale
  4. All mutations update UI optimistically, sync to Postgres with rollback on failure

Dashboard perceived load time: 3.3s → effectively 0ms. Database queries reduced by 60-90% for returning users.

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. The page shell renders immediately via SSG; charts and compute-heavy aggregations populate asynchronously without blocking initial render.

Self-Directed Architecture: Extending Local-First to Authenticated Data

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.

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

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 21,000+ items with zero user-facing latency
  • Cost: $12/month vs $119-188/month for equivalent traditional infrastructure
  • 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.