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)