All posts
No-Code & Tools

How We Cut Cloudflare D1 Row Reads by 98.8% With Cache-First SSR and Query Snapshots

One production content workload was reading roughly 344,000 D1 rows per hour. After changing the read architecture—not merely adding one index—the same system settled near 4,000 rows per hour.

Elvis Onunwa02 October 20267 min readLast updated: 02 October 2026
How We Cut Cloudflare D1 Row Reads by 98.8% With Cache-First SSR and Query Snapshots

TL;DR

  • The measured workload fell from roughly 344,000 D1 rows read per hour to about 4,000—around a 98.8% reduction—while query executions fell from about 3,000/hour to 376/hour.
  • The biggest wins came from changing the public read architecture: cache before D1, precompute expensive related/category views, bound queries with indexes and dedupe refresh work.
  • A global homepage ranking query was replaced by small indexed top-N category queries batched in one round trip; related-content live scans became independent snapshots.
  • Optimize the source query before caching it, then make cache misses controlled so many requests cannot rebuild the same expensive snapshot simultaneously.
On this page⌄

One production content workload I worked on was reading roughly 344,000 Cloudflare D1 rows per hour.

After the architecture changes, the measured rate was roughly 4,000 rows per hour.

That is about a 98.8% reduction.

Query executions also fell from roughly 3,000 per hour to 376 per hour.

There was no single magic index.

The improvement came from changing the question the application asked D1:

Instead of asking the database to rebuild the same public view for every request, can we compute bounded read models only when they actually need to change?

That shift—cache first, database second—made the biggest difference.

The starting architecture

The application was a content-heavy SSR site on Cloudflare Workers + D1.

The expensive paths included:

  • homepage category sections;
  • latest/trending content;
  • related stories;
  • tag matching;
  • per-request public content aggregation;
  • duplicate/hydration reads.

The database was doing too much derivation in the request path.

A visitor asking for one article could cause the system to rediscover related stories from the full content graph. A homepage refresh could recompute top stories across categories. Cache expiry could trigger the same rebuild several times.

The system worked functionally.

Its read economics were wrong.

Step 1: rank by total rows read, not by how scary the SQL looks

We used D1 analytics to identify the statements consuming the largest share of rows.

Examples from the investigation included:

  • one related-content statement: ~147,820 rows across 85 executions;
  • other related/tag work consuming large scan totals;
  • category/recent homepage queries with high execution and scan ratios;
  • later, one global homepage WITH ranked rebuild accounting for about 76% of a measured window.

This stopped us optimizing code by intuition.

The first target should be the SQL that moves the usage graph.

For the metric distinction, see D1 Rows Read vs Query Count.

Step 2: move public cache in front of D1

The original mental model was:

request → SSR → D1 → render

The new model became:

request → edge/cache snapshot → render

with D1 used on controlled refresh paths.

For public data that can tolerate short freshness windows, this is a major architectural difference.

Examples:

  • homepage snapshot;
  • category snapshot;
  • related-content snapshot;
  • public site configuration.

A normal page request should not need to ask D1 to rediscover data that hundreds of previous visitors already requested seconds ago.

Step 3: use stale data safely during refresh

A naive TTL cache creates a cliff.

At 12:00:00 the cache expires. Ten users arrive at 12:00:01. Ten requests rebuild the same expensive data.

That is a cache stampede.

We changed the refresh behaviour so the system could:

  • serve a last-known-good stale snapshot for an acceptable window;
  • trigger a controlled rebuild;
  • dedupe concurrent rebuild attempts;
  • replace the snapshot only after a successful refresh.

This reduces D1 pressure and improves resilience when the database is temporarily constrained.

Not every data type can be stale. Payments, authorization decisions and mutable customer balances require stronger consistency. Public content recommendations do not have the same requirement.

Related-content was one of the biggest offenders.

The page needed a handful of related stories, but the SQL repeatedly evaluated tags/categories/candidates.

Instead of recalculating that relationship for every article request, we moved toward an independent related-content snapshot with a refresh lifecycle.

The model became:

  1. request article;
  2. read its existing related snapshot;
  3. if snapshot is fresh, use it;
  4. if stale, serve the previous safe value and rebuild once;
  5. persist the new bounded result.

That eliminated a large recurring tag-matching scan from the hot request path.

The user does not care whether four related cards were calculated 30 minutes ago or 30 milliseconds ago if the content remains sensible.

Step 5: replace one global ranking query with bounded top-N queries

A later optimization taught an important lesson.

We had consolidated homepage category work into a global ranking query using a WITH ranked approach.

It reduced round trips but became a rows-read hotspot because it ranked a broad candidate set.

The better design was:

  • one small top-7 query per category;
  • each query shaped to use the public/category/recency index;
  • queries batched into one D1 round trip.

So the network/database call count stayed controlled while each SQL statement examined a small relevant range.

This is why I do not optimize for “one SQL statement.”

I optimize for bounded work.

Step 6: add indexes that match public read paths

Indexes were still important.

A useful public recency index can let D1 answer questions such as:

Give me the latest published rows in this category.

without scanning the entire article set.

But an index only helps when query predicates/order can use it.

For every hot statement, check EXPLAIN QUERY PLAN.

Look for:

  • table scans;
  • temporary sort structures;
  • unused indexes;
  • broad OR/EXISTS conditions;
  • filters applied after an expensive intermediate result.

Then shape the query around the access path you actually need.

Step 7: remove duplicate hydration/loader fetches

Once the large scans were controlled, duplicate executions became more visible.

One example was a client-side breaking/ticker component fetching data immediately after SSR had already supplied it.

The fix was small, but repeated across traffic it mattered.

Audit:

  • nested loaders;
  • page metadata;
  • hydration effects;
  • global components;
  • client query libraries;
  • route transitions.

If the same public data is fetched twice during one page lifecycle, decide which layer owns freshness.

Step 8: separate rebuild traffic from user traffic

A powerful architectural improvement is to make derived public data its own resource.

The homepage snapshot is not “whatever SQL the homepage happens to run today.”

It is a read model with:

  • value;
  • built-at timestamp;
  • expiry/freshness rule;
  • last successful value;
  • rebuild function;
  • rebuild dedupe/lock;
  • observability.

This makes performance predictable.

You can measure rebuild cost separately from page traffic and change the derivation without rewriting every route.

The before/after model

Before:

Visitor
  -> Worker SSR
     -> latest query
     -> category queries
     -> tag/related scans
     -> image/metadata queries
     -> render
Browser hydration
  -> fetch some of it again

After:

Visitor
  -> Worker
     -> cache/read snapshot
     -> render

Controlled refresh
  -> bounded indexed D1 queries
  -> build snapshot once
  -> cache/store result

The database still exists.

It is no longer in the center of every public read.

Measured result

The key measured change was approximately:

Metric Before After
D1 rows read/hour ~344,000 ~4,000
Query executions/hour ~3,000 ~376
Rows-read reduction ~98.8%

Traffic windows are never perfectly identical, so I treat these as operational measurements rather than laboratory constants.

But the order-of-magnitude change was large enough to validate the architecture.

We had removed systemic repeated work.

What I would do first on another D1 app

If I inherited a D1 app with high row usage today, I would:

  1. export/query the top statements by rows read;
  2. calculate rows/execution;
  3. map each hot SQL statement to a route or rebuild;
  4. fix full scans and missing indexes;
  5. identify public data that does not need per-request freshness;
  6. create cache/snapshot ownership for that data;
  7. dedupe rebuilds;
  8. remove SSR/hydration duplication;
  9. add stale/circuit-breaker behaviour for optional public features;
  10. remeasure rows/hour and queries/hour.

That sequence gives you evidence after every change.

Do not optimize yourself into stale business truth

Cache-first is not a universal consistency model.

Use it aggressively for derived/public information such as:

  • news sections;
  • recommendations;
  • marketing content;
  • event schedules that change infrequently.

Be much more careful for:

  • payment verification;
  • inventory reservations;
  • permissions;
  • balances;
  • unique booking slots;
  • write-after-read business rules.

Architecture is choosing which data can be stale and which data must be authoritative now.

The bigger lesson

The 98.8% reduction did not come from “making D1 faster.”

It came from asking D1 fewer, smaller, more intentional questions.

That is usually the stronger optimization target.

If your Cloudflare app is near D1 limits and you want to know whether the fix is indexes, caching, query redesign or SSR architecture, contact me or see my MVP development service. I can help build the rows-read budget before the database becomes an outage trigger.

Sources checked

Frequently asked questions

Questions, answered.

How can I reduce Cloudflare D1 rows read?

Start with query analytics, fix statements that scan too many rows, remove duplicate SSR/hydration reads, cache public read models before D1, and precompute expensive derived views such as related content or category rails when they do not need per-request freshness.

Does Cloudflare Cache API reduce D1 rows read?

Yes, when the application can serve a cached response or data snapshot without querying D1. The key is to check cache before the database and control how stale/missing entries are rebuilt.

Should I use one big SQL query or several small queries?

Measure scan cost. In our case, one global ranking query scanned far more data than several bounded indexed top-N queries batched together. Fewer SQL statements is not automatically fewer rows read.

What is a query snapshot?

It is a precomputed read model stored for reuse—such as homepage category sections or related-content IDs—so every user request does not rerun the expensive derivation query.

NEED HELP WITH THE BUILD?

Turn what you learned here into a clearer website or MVP.

Share the goal, current stage, and constraints. I’ll help you identify the smallest responsible next step.

Continue reading

Related notes.