A page can make one HTTP request from the visitor and still create dozens of database operations behind the scenes.
That is the pattern I call SSR fan-out.
I have investigated Cloudflare D1 applications where the database problem was not one spectacular query. It was the multiplication of several ordinary-looking behaviours:
- root loader reads navigation/configuration;
- child loader fetches page data;
- a second helper fetches overlapping page data again;
- related-content logic scans candidate rows;
- each image requests metadata/authorization from D1;
- category sections execute independently;
- hydration refetches something SSR already loaded;
- an admin route eagerly loads hundreds of records;
- cache misses rebuild several snapshots at once.
Individually, each decision can look defensible.
Together, one page view becomes a database event.
If your D1 rows read are exploding, I would investigate multiplication first, not assume you simply need a bigger plan.
Two multipliers create the bill
D1 usage can grow from two different directions:
Multiplier 1: executions
How many times does SQL run?
A perfectly indexed query that scans five rows is still wasteful if a single page executes it 30 times unnecessarily.
Multiplier 2: scan cost
How many rows does each execution read?
A query that runs only 50 times can dominate usage if each execution scans thousands of rows.
The total effect is roughly:
executions × rows scanned per execution
You need to reduce the right side of that equation.
My D1 rows read vs query count guide explains the metrics themselves. This article is about where the multiplication comes from.
Root loader + child loader duplication
Modern SSR frameworks make nested routes convenient.
That can also make it easy to read the same data at multiple levels.
Imagine:
- root loader fetches site configuration and current event;
/registerloader independently fetches current event and pricing;- a shared
RegistrationShellserver helper fetches the event again; - a child step asks for country pricing and re-fetches the registration context.
The visitor sees one registration page.
D1 may see four overlapping reads before the browser becomes interactive.
In one application audit, the public SSR baseline was around a dozen SQL reads before considering all downstream media/admin behaviour.
The fix starts by drawing the loader tree and asking:
Which layer owns each piece of data?
If parent data is already available to a child, reuse it instead of re-deriving it from D1.
Hydration can fetch what SSR already fetched
A subtle duplication happens when server rendering fetches data and the browser immediately runs a client fetch for the same information after hydration.
This often appears in:
- tickers;
- breaking-news bars;
- user/session widgets;
- category rails;
- pricing/configuration hooks;
- “latest” components.
The developer may see the client request as harmless because it keeps data fresh.
At scale, every page view becomes:
SSR read + hydration read
for information that changed zero times between those moments.
We fixed one production component where a breaking-news/ticker path unnecessarily fetched again during hydration. Removing that duplicate request cut recurring database work without changing the UI.
Category fan-out
Homepages are dangerous because they aggregate many small content blocks.
A page may need:
- 7 politics stories;
- 7 business stories;
- 7 sports stories;
- 7 entertainment stories;
- and so on.
One implementation might run a separate query for every category.
That is not automatically bad—bounded indexed queries can be very efficient.
The problem appears when every query scans a large candidate set or when the page rebuilds the whole structure far more often than needed.
In one measured window, category queries executed 34 times and read roughly 28,370 rows. Another recent-content statement executed just four times but scanned roughly 7,960 rows.
The right fix was not simply “make it one SQL query.” A single global ranking query later became its own hotspot.
The goal is bounded work, not minimum statement count.
Global ranking/window queries can hide enormous scans
A clever SQL query can generate top-N results for every category using a window function or global CASE ranking.
It looks elegant because the application makes one database call.
But the database may need to:
- scan a broad public-content set;
- partition/rank rows;
- sort candidates;
- return only a few rows per category.
I encountered a homepage snapshot rebuild where one WITH ranked ... style query accounted for roughly three quarters of the measured rows in that window.
We replaced the global ranking approach with small indexed top-7 queries per category, batched in one D1 round trip.
More SQL statements internally; dramatically less scanned work.
This is why “one query” is not a performance strategy.
Related content and EXISTS scans
Related-content logic often becomes expensive as the article table grows.
Common patterns include:
EXISTSagainst tag junction tables;ORconditions across category/tag/topic;- scoring matches with
CASE; - excluding the current content;
- recency ordering;
- fallback queries.
A four-card component can scan thousands of rows per request.
If that component appears on every article page, traffic multiplies the problem quickly.
One production query I measured consumed about 147,820 rows across 85 executions.
The eventual architecture moved related-content generation away from repeated live recomputation toward bounded snapshots with their own refresh lifecycle.
The user still gets related stories. D1 does not have to rediscover the same relationships on every page view.
Correlated lookups and N+1 work
N+1 is not limited to ORMs.
You can create it manually:
- fetch 20 stories;
- for each story fetch its category;
- for each story fetch its image metadata;
- for each story check some auxiliary state.
That can turn one list into 61 queries.
Sometimes a join or batched lookup is better.
Sometimes the data belongs in a snapshot or denormalized read model.
The important thing is to recognize the shape.
When debugging, group SQL by request/route rather than viewing every query as an independent event.
Image routes can quietly become database routes
A browser may request 20 images for one page.
If your asset endpoint performs a D1 lookup for every request to find:
- metadata;
- ownership;
- MIME type;
- authorization;
- R2 key;
then the image grid is also a database workload.
Now add responsive image variants, social crawlers, cache misses and repeat visitors.
The visible page has no new text content, but D1 usage climbs.
I cover this separately in Your Images Might Be Hitting D1.
Eager admin loaders
Public traffic is not the only source.
An admin dashboard may load 250 records, statistics, media, categories and audit logs at once because “only admins use it.”
But one admin refreshing repeatedly can generate large row reads if those queries scan/sort unindexed tables.
Audit pages are particularly easy to neglect. A missing timestamp index on a log table can make a simple “latest 100 events” view increasingly expensive over time.
Internal pages deserve query budgets too.
Runtime schema/index checks
Another pattern is running schema or index introspection during normal request handling.
A development safety check can accidentally become production traffic:
- does table exist?
- does index exist?
- has migration run?
- inspect pragma/schema metadata.
Those checks belong in migration/deployment/health workflows where possible, not in every page request.
Production request paths should assume a deployed schema contract and fail clearly if that contract is broken.
Cache misses can stampede
Caching does not automatically save you if many requests miss at the same time.
Suppose a homepage snapshot expires every five minutes.
If 20 requests arrive immediately after expiry and each independently rebuilds the snapshot, your cache has multiplied the expensive query by 20.
Use one or more of:
- deduped rebuilds;
- stale-while-revalidate style behaviour;
- short lock/lease;
- background refresh;
- last-known-good stale copy;
- circuit breaker.
The best cache is not only a TTL. It is a controlled rebuild lifecycle.
How I trace one SSR request
I like to build a request budget.
For one route, record:
| Layer | Calls | Rows read | Why? |
|---|---|---|---|
| Root loader | |||
| Route loader | |||
| Shared helpers | |||
| Related content | |||
| Asset metadata | |||
| Hydration | |||
| Background rebuild |
Then ask of every line:
- Does it need to happen for this request?
- Is this data already available?
- Can it be read from cache/snapshot?
- Is the SQL index-bounded?
- Can several calls be batched?
- Can rebuild work be deduplicated?
That exercise is usually more useful than adding random indexes based on table size.
Fix order matters
I normally prioritize:
1. Catastrophic scans
A query scanning thousands of rows every execution deserves immediate attention.
2. High-frequency duplicates
Hydration duplication, per-item lookups and nested-loader overlap can create easy wins.
3. Expensive rebuild paths
Snapshot/category/related-content refresh should be bounded and deduped.
4. Caching
Once the underlying query is sane, caching reduces how often it needs to run.
5. Admin/internal paths
They may be lower traffic but can still grow unbounded as data accumulates.
This sequence avoids caching an unnecessarily expensive query and calling the problem solved.
Treat database work as architecture, not an afterthought
The core lesson from these incidents was not “D1 is expensive.”
D1 can be extremely efficient when queries are bounded and the request architecture respects the edge/database boundary.
The problem was that application convenience had hidden multiplicative work.
One page request was not one database task.
Once we measured the actual fan-out, we could redesign the read path around cached snapshots, bounded queries and explicit ownership of data loading.
If your Worker looks healthy but D1 usage keeps climbing faster than traffic, contact me or see my MVP development service. I work on exactly this class of production issue: tracing the system from request to database rather than patching the loudest query in isolation.



