Building CardCore - March 29, 2026 - Performance Hardening, 142 Tickets Closed, and 2,000 Cards Sorted
The system just got fast
Not incrementally-better fast. Dramatically, noticeably fast.
Our System Health dashboard was showing "Degraded Performance" with 7 slow endpoints. Store Inventory was taking 6.5 seconds. The Market Dashboard took over 12 seconds. Card Filters was nearly 10 seconds on first load. For a platform that needs to handle 200-300 people at a convention, that's unacceptable.
The root cause? Every single inventory read was blocking on a full market price sync. Every time a customer browsed the store or a staff member checked inventory, the server would stop everything, fetch all 3,000+ product prices from the cards database, batch-update the inventory table, backfill box opening records, and then - finally - return the data the user actually asked for. On a 4-node PM2 cluster, that meant up to 4 syncs running simultaneously, all doing the same work.
After today's hardening pass, here's where we stand:
| Endpoint | Before | After |
|---|---|---|
| Store Inventory | 6,589ms | 173ms |
| Sealed Inventory | 6,639ms | 258ms |
| Card Filters | 9,975ms | 158ms |
| Market Dashboard | 12,475ms | ~50ms (cached) |
| Box EV Calculator | 3,839ms | ~26ms (cached) |
| Playset Costs | 3,135ms | ~33ms (cached) |
That's not a typo. Store Inventory went from 6.5 seconds to 173 milliseconds. A 38x improvement.
How we got there
Three big changes:
1. Moved price sync to the background. Customer reads no longer block on sync. The system fires off the sync in the background and returns data immediately. Prices might be up to 5 minutes stale, which is completely fine - they only change once a day when the pipeline runs anyway. Nobody's making a purchase decision based on whether the price updated 3 minutes ago versus right now.
2. Made sync cluster-safe. This was the scariest part. The first implementation used PostgreSQL advisory locks, which seemed clever until I realized they don't work with connection pooling. An advisory lock is bound to a specific database connection. With a connection pool, the lock gets acquired on one connection, the connection goes back to the pool, the guarded work runs on a different connection, and the unlock hits yet another connection. Zero protection.
Replaced the whole thing with an atomic compare-and-swap pattern using a market_sync_status table. One UPDATE with a WHERE clause that checks both freshness and whether another process already claimed the sync. PostgreSQL's row-level locking serializes the UPDATEs automatically. No advisory locks needed, no connection affinity required, no stuck locks possible. If a process crashes mid-sync, there's a 2-minute timeout that automatically unlocks it.
Verified it on production: fired 20 concurrent requests across all 4 PM2 nodes. Exactly 1 sync ran. The other 19 correctly skipped.
3. Cached the tool endpoints. Market Dashboard, Box EV Calculator, and Playset Costs all run heavy SQL queries that scan tens of thousands of rows. The data changes at most once a day. Wrapped them in our existing cachedQuery infrastructure with appropriate TTLs (5-10 minutes). First hit is still slow, every subsequent hit is <50ms.
Found a critical financial bug
During the numeric safety sweep, I found something that made my stomach drop. The store credit application route had this line:
const effectiveAvailable = +(currentBalance + detail.store_credit_applied).toFixed(2);
Looks fine, right? Except currentBalance and detail.store_credit_applied can come from PostgreSQL as strings. And in JavaScript, "10.50" + "5.25" doesn't give you 15.75. It gives you "10.505.25". String concatenation, not addition.
This was in the code path that calculates whether a customer has enough store credit to apply to an order. If both values came back as strings, the available balance would be calculated as a nonsensical number. It might still "work" in some cases (JavaScript is weird about type coercion), but it could also silently compute the wrong balance, approve a credit application that should be denied, or deny one that should be approved.
Fixed it with explicit Number() wrapping. Then swept the entire codebase and found 17 more .toFixed() calls on values that could be PostgreSQL strings. Fixed all of them. Built a shared src/lib/format.ts utility with toNumber(), formatCurrency(), and safeFixed() so this class of bug can't happen again. Wrote 24 unit tests covering the exact crash and wrong-math scenarios.
Closed 142 tickets in one session
We had 142 tickets sitting in Testing state. Some from weeks ago, some from months ago. Did a full visual QA pass on production using Chrome - checked every major page at desktop and mobile widths, verified UI rendering, tested interactions, ran the full Playwright suite (1,762 tests passed on desktop, 595 on mobile).
Closed 139 tickets that were confirmed working. Left 3 that need specific real-world conditions to verify (Square Terminal hardware, real pay-in-person order, live tournament entry fee). Testing queue went from 142 to 3.
Then users reported 13 bugs. Fixed 7 of them on the spot:
- Three cards in Twilight of the Republic (Creative Thinking, Aid from the Innocent, Unnatural Life) had missing or wrong card attributes. SQL fix, verified live in Chrome.
- Deck list view mode wasn't showing card numbers. Players couldn't tell which printing to pull from their physical collection without going into edit mode. Five-line fix.
- The "Bulk" badge was showing on cards worth $3+ because it checked whether a bulk rule existed, not whether the floor price was actually above the market price. Boba Fett Prestige at $3.25 was showing as "Bulk" because there was a $0.75 floor rule for Rare Prestige cards.
All 7 bugs marked as fixed in the bug tracker with resolution notes. Discord #bugfixes channel got automatic notifications for each one.
Sorted 2,000 cards from A Lawless Time
While all the code work was happening, I was also physically sorting nearly 2,000 cards from A Lawless Time by hand. That's the real work of running a card store - the unglamorous part where you sit at a table with stacks of commons, uncommons, rares, and variants, getting each one into the right pile so it can be entered into inventory.
The Set Browse tool I built this morning? Used it to enter the cards. Every card in the set listed in number order with +/- buttons. No searching, no scanning, just scroll through and tap quantities. It's the exact workflow for cracking boxes and entering pulls.
What else shipped
-
Windows Task Scheduler automation for the market price sync. PowerShell scripts to install, verify, and remove the scheduled task. Runs every 5 minutes as SYSTEM, survives reboots, no login required. The health dashboard now shows when the last sync ran and how long it took.
-
Memory health check fix. The old check compared V8's
heapUsedtoheapTotal, which always shows ~90% because V8 dynamically sizes the heap close to usage. The actual V8 limit was 4,288MB - we were using 122MB of it. Fixed the check to compare RSS against PM2's restart threshold. Also added explicit--max-old-space-size=512to PM2 config for deterministic GC behavior. -
Cache observability. The query cache now logs hit/miss/coalesce stats every 5 minutes and logs individual cold-cache fetches over 100ms with exact duration and cache key. The health dashboard shows a "Query Cache" system check.
-
22 new Playwright E2E tests covering the Sealed Products page, Box Opening wizard Set Browse mode, System Health dashboard, and acquire page tab navigation.
-
Card browse performance. Added 3 database indexes on the cards table and HTTP Cache-Control headers for browse requests. First load is still ~800ms (complex CTE over 57K rows) but repeat visits serve from browser cache for 5 minutes.
What's next
Six user-reported bugs remain open, triaged by difficulty. Two easy cosmetic fixes (20 min each), two medium logic fixes (1-2 hrs each), and two harder deck import issues (2-6 hrs). Convention prep continues - two booth events coming up in April. The system is in the best shape it's ever been, and today's performance hardening means it can actually handle a crowd.
Today was 34 commits, 25+ Plane tickets, 7 bug fixes, 2,000 cards sorted, and an Instagram post about deck metrics. Not bad for a Saturday.