The symptom
A content platform, a directory-style site with thousands of dynamically filtered listing pages, was showing serious performance problems in a routine site audit. A full crawl of roughly 6,000 pages, combined with PageSpeed Insights (PSI) API data, turned up two things that looked unrelated at first glance:
- Server response times were bad, and occasionally catastrophic. Median time-to-first-byte (TTFB) sat around 0.7 seconds, with over 1,500 pages taking longer than a second. Two pages didn’t just respond slowly, they hung for 912 seconds and 58 seconds before returning anything at all.
- Core Web Vitals were failing for over a third of the site. 37% of pages scored below the “Good” threshold, driven mainly by Largest Contentful Paint (LCP). Cumulative Layout Shift (CLS) was even more telling: 24% of pages scored poor (>0.25), and the shift amounts weren’t random, the same handful of values kept recurring across hundreds of pages.
That last detail, identical CLS values recurring at scale, was the first clue that this wasn’t hundreds of unrelated bugs. It was a small number of structural problems repeating themselves across a template-driven site.
Stack: Next.js (App Router), DynamoDB, an in-memory Node cache layer, deployed as a single Node instance on AWS Elastic Beanstalk.
The investigation
The initial hypothesis, based on the response-time patterns, was a classic one: missing database indexes. The slow pages all seemed to combine a content topic with a low-traffic region, a fairly natural, if unproven, story about full table scans on sparse queries returning few results.
Digging into the actual request path told a different, more interesting story.
Every listing page rendered by running a full-table database scan filtered down to that page’s specific combination of parameters, and caching the result of that exact query under a key unique to that combination. With thousands of possible parameter combinations across the site, a full crawl generated thousands of distinct cache entries, each requiring its own cold scan against the database. The slow pages were, without exception, this type of dynamically filtered listing page, pages served by a direct key lookup, and static content pages, were never slow. That ruled out the database engine itself as the bottleneck. It also ruled out the “sparse queries are slow” theory directly: the single worst-offending page scored a near-perfect performance grade with a fast load time when tested in isolation, despite a multi-minute response time during the crawl. The data wasn’t slow. The caching strategy was working against itself at scale, a portion of pages sitting in the one-to-a-few-second range were single cold scans; the long tail out past three seconds was concurrent scans triggering database throttling and automatic retries.
There was a second, quieter problem compounding the first. The full underlying dataset was already being loaded and cached once, in its entirety, under a single shared cache entry. But each individual listing page then queried the same dataset again independently and cached its own slice of it separately. That meant the same data existed in memory in two forms at once: the shared snapshot, and a duplicated copy spread across every one of the thousands of per-page cache entries, plus similar duplication for individual-record lookups. Because the cache had no size limit and only swept out expired entries on an hourly cycle, even short-lived entries weren’t reclaimed for up to an hour, so during an extended crawl or a busy traffic period, they accumulated by the thousands. This grew the process’s memory footprint steadily over time, increasing garbage-collection pressure and slowing every request, not just the cached ones, as load continued. That’s the mechanism behind requests being fast early in a crawl and progressively slower toward the end. Layered on top, the database client had no request timeout and no bounded retry limit, so a handful of throttled requests didn’t fail fast, they retried and backed off for minutes at a time. That’s what produced the multi-minute outliers: not a broken per-page query, but a slow, un-timeboxed retry loop with no circuit breaker.
The layout-shift pattern had a similarly simple explanation once someone looked. The recurring shift values weren’t several different bugs, they came from a single loading-state placeholder used on every listing page, sized differently from the real content that replaced it once it loaded. Because that placeholder component was shared across the whole site, the same fixed-magnitude shift showed up over and over, varying only by page layout. A secondary, smaller shift came from a map component whose loading indicator was a different size than the map that replaced it.
The fix
Rather than treating this as a long list of independent tickets, the work was organized around root causes, in order of impact, across four phases:
Phase 1: Single source of truth cache. Instead of caching a separate result set for every possible combination of filters, the underlying dataset is cached once under a small, fixed set of base keys. Every page then derives its data by filtering or looking up against that in-memory snapshot instead of issuing its own database read. This collapsed thousands of dynamic, per-page cache entries down to a handful of fixed keys, removed the data duplication, and eliminated the unbounded memory growth. The cache’s cleanup interval was shortened and a generous but firm size cap was added as a regression guardrail, so any future change that accidentally reintroduces per-page caching fails loudly and immediately instead of leaking silently over time. A latent correctness issue was fixed alongside this: code that mutated shared objects in place was corrupting the shared snapshot across requests, so records are now cloned before any in-place mutation. Cache invalidation moved from a manual admin-panel action to automatic invalidation whenever the underlying data changes, with the manual option kept as a fallback.
Phase 2: Harden the database client. The client now enforces a bounded connection and request timeout and a capped retry count, and the data-access helpers are wrapped in a time-boxed operation that returns an empty result and logs the failure rather than hanging indefinitely. A safeguard was also added against runaway pagination loops. This directly eliminated the multi-minute outliers.
Phase 3: Fix the layout-shift source at the template level. The shared loading placeholder was resized to match the real content’s dimensions (and, with Phase 1 making the data load near-instantly, the loading state could largely be removed for that section entirely). The map component’s loading indicator was given the same footprint as the resolved map. Because both were shared components, fixing them once resolved the recurring shift across every page that used them.
Phase 4: Cheap LCP and asset wins. List-item thumbnails were set to a sensible image quality and given lazy loading for anything off-screen, while only the genuine above-the-fold image was marked as a loading priority. The site’s web font configuration was trimmed down to only the weights actually used, cutting unnecessary download size.
Two lower-priority ideas from the original audit were deliberately dropped. Converting all images to next-gen formats needed to happen at the upload pipeline, not as a page-level fix, and was moved to its own project. The database-indexing theory, once the real bottleneck was identified, turned out not to apply at all, the dataset involved was small, and the problem was never how it was queried, but how many times the same data was needlessly re-fetched and re-cached per page. Server-side static generation for listing pages was scoped as an optional future phase, to be revisited once the impact of the caching fix could be measured on its own.
The takeaway
The most expensive part of this investigation wasn’t writing the fix, it was resisting the obvious explanation. A pattern that looks like “the database is slow” can just as easily be “the application is asking the database the same question thousands of times when it only needed to ask once.” The giveaway was in the data itself: a page that failed catastrophically under crawl load performed perfectly when tested alone, and a handful of layout-shift values kept repeating across hundreds of unrelated pages. Both were symptoms of a small number of shared components and caching decisions, not thousands of individual page problems, which is exactly why four focused changes fixed the whole site.
Web Developer @ Wingravity, turning ideas into clean, working code. Curious about what we do, or want to team up? Say hi through our contact form.



