QA Deep Dive: QA-009 Search Image Caching
A detailed look at QA-009 - how a 4-session feature became 10 sessions through real-device testing, and the bugs that only appeared on a decade-old iPad.
Focused work time: ~4 hours (morning of Jan 8, 2026) Issues discovered: 7 total (4 compatibility issues + 3 critical bugs) Final output: 2,793 lines added across 20 files, 1,735 lines of documentation
The Original Issue
Search result images displayed as white boxes on iOS 9 Safari.
What Real-Device Testing Discovered
4 compatibility issues that weren't in the original plan:
- iOS 9 Safari blocks some external image URLs (CORS and security policies)
- WebP format not supported on iOS 9 (added in Safari 14)
awaitfor image caching added 4-5 seconds to response time- JPEG quality at 85 was too low for high-DPI displays
How the Sessions Evolved
| Session | Task | What Happened |
|---|---|---|
| 1 | Model & Service | Planned |
| 2 | API Integration | Planned |
| 3 | Frontend Updates | Learned: JS renders cards, not templates |
| 4 | Cleanup & Docs | Planned |
| 5 | Performance Fix | Response time 4-5s -> <1s with threading |
| 6 | Image Quality | Bumped JPEG 85->92 for high-DPI |
| 7 | Production Config | Gunicorn threads, monitoring |
| 8 | Progressive Loading | Added spinners + polling for UX |
| 9 | Load More Fix | 3 critical bugs discovered |
| 10 | Performance Opts | 75% fewer DOM queries |
Sessions 1-4 were the original plan. Sessions 5-10 emerged from testing on the actual iPad.
The Three Critical Bugs (Session 9)
These bugs only appeared when testing pagination with cached images on the real iPad. Unit tests and desktop browser testing missed them entirely.
Bug #1: escapeSelector() was insufficient
// BROKEN - Only escaped " and \
function escapeSelector(str) {
return str.replace(/["\\]/g, '\\$&');
}
// FIXED - Escape ALL CSS special characters
function escapeSelector(str) {
return str.replace(/([!"#$%&'()*+,.\:;<=>?@\[\\\]^`{|}~])/g, '\\$1');
} URLs contain colons, dots, slashes, and hash symbols. document.querySelector('[data-url="https://example.com/path"]') failed silently because the URL characters weren't escaped.
Bug #2: Spinner logic wouldn't replace external URL images
The code checked if the image source started with / (local path) before replacing with a spinner. External URLs don't start with /, so they were never replaced.
// BROKEN
if (img.src.startsWith('/')) {
img.src = '/static/spinner.gif';
}
// FIXED - Check for any valid src, not just local paths
if (img.src && img.src !== '') {
img.dataset.originalSrc = img.src;
img.src = '/static/spinner.gif';
} Bug #3: Pagination rendered ALL results every time
The "Load More" button was supposed to append new results. Instead, it re-rendered the entire result set, causing duplicates.
// BROKEN - Replaced all content
resultsContainer.innerHTML = renderAllResults(allResults);
// FIXED - Append only new results
const newResultsHtml = renderResults(newResults);
resultsContainer.insertAdjacentHTML('beforeend', newResultsHtml); Why Desktop Testing Missed These
- Desktop browsers handle CSS selectors more leniently - Chrome's
querySelectordidn't fail on unescaped URLs - External images loaded fine on desktop - No iOS 9 security policies blocking them
- Pagination wasn't tested with slow image loading - Desktop cached images instantly, masking the race condition
The Takeaway
Complex features WILL grow beyond the plan. The implementation phase/session structure keeps each chunk manageable even when the total expands - you can always ask Claude to add a new Session with a specific objective to a Phase. Four sessions became ten, but each session was still focused and completable.
Commit Reference
6dc3bfa - feat: Add progressive image caching for search results (QA-009)