You optimize your page loader.
The SSR route makes only a few Cloudflare D1 queries.
The dashboard still shows far more database activity than the page traffic seems to justify.
Look at the images.
I have audited a Cloudflare application where media delivery was part of the D1 problem because asset requests were not “just files.” Each image route could perform database work to resolve metadata or an object-storage key before returning the image.
That changes the workload completely.
A page with 20 images can become:
1 HTML request + 20 asset requests + 20 database lookups.
Add responsive variants, bots, social previews, cache misses and repeat navigation, and your media layer can quietly consume a significant share of D1 rows.
Why this architecture happens
A common upload system stores:
- binary file in R2;
- metadata row in D1;
- application exposes
/media/:assetId; - Worker receives the request;
- Worker queries D1 for the asset row;
- row contains R2 object key/MIME/ownership;
- Worker reads object from R2 and returns it.
This design is understandable.
It gives the application one stable internal asset ID and lets the database own metadata.
The problem is not the design itself.
The problem is making the same D1 resolution mandatory for every public file request forever.
Browser subresources multiply differently from page views
Suppose the homepage includes:
- hero image;
- 12 story thumbnails;
- logo;
- 5 representative photos;
- two decorative assets.
That is 21 image requests.
If every one performs a D1 point lookup, 1,000 homepage views can create roughly 21,000 additional database queries before considering browser caching or variants.
If the lookup is indexed and scans one row, that may still be manageable.
If the query scans multiple rows or performs ownership joins, the rows-read effect grows faster.
The important point is that HTML traffic is not asset traffic.
Do not estimate database load only from route views.
Responsive images can multiply one logical image
Modern image markup may request different sizes depending on device and layout.
Browsers usually choose one candidate from srcset, but other systems may request additional forms:
- preload;
- social crawler/Open Graph fetch;
- admin preview;
- image optimizer/proxy;
- thumbnail generation;
- transformed variants.
If every variant starts by resolving the same asset record through D1, one original upload can create many metadata lookups over its lifetime.
The database is now in the hot path for a resource that is otherwise highly cacheable.
Public assets should usually have a database-free fast path
For truly public media, I prefer a serving path that can be resolved without a live relational query on every hit.
Options include:
Stable R2/object key in the URL
If the public URL safely maps to the object key, the Worker can read R2 directly.
Immutable generated asset URL
Store a public path when the asset is published so rendering code does not need to resolve an internal ID repeatedly.
Edge-cached asset response
Let Cloudflare cache the media response so repeat requests do not reach your Worker/D1 path.
Static generated derivatives
For featured images, thumbnails or known public assets, generate them ahead of time and serve by deterministic path.
The right model depends on access/security requirements.
The principle is:
do not require relational state for a request whose answer rarely changes.
Protected media is different
Some assets genuinely require authorization:
- private documents;
- KYC uploads;
- paid downloads;
- tenant documents;
- admin-only media.
Do not remove the permission check just to reduce D1 usage.
Instead, make the protected path explicit.
Potential approaches include:
- signed short-lived URLs;
- cached authorization where safe;
- indexed point lookups;
- separate public/private buckets or namespaces;
- capability tokens;
- gateway route that verifies once and issues temporary access.
Security is a constraint, not an optimization target to delete.
Metadata lookup can be cheap if the key is truly indexed
If you must query D1 per asset, make the query bounded.
For example:
SELECT r2_key, mime_type, visibility
FROM media_assets
WHERE id = ?
LIMIT 1;
with id as a primary/indexed key is fundamentally different from:
SELECT ...
FROM media_assets
JOIN users ...
JOIN projects ...
WHERE some_nonindexed_field = ?
The first can be close to a point lookup.
The second may scan or join much more data per image request.
Use D1 query analytics and EXPLAIN QUERY PLAN rather than assuming “it’s only one metadata row.”
Watch for authorization repeated on every public image
An especially wasteful pattern is applying private-style authorization to media that is already intentionally displayed on a public page.
For example:
- public homepage renders an event representative photo;
- browser requests
/api/assets/123; - route queries asset row;
- queries related content/site record;
- decides yes, this public image can be seen;
- reads R2.
The page already published the image as public, but every viewer re-proves that fact.
A better model may persist an explicit visibility/publication state and create a cacheable public URL once approved.
Asset existence checks can also become D1 work
Some media systems validate on every render:
- is the asset still active?
- is the linked record deleted?
- does the R2 object still exist?
- is the site logo configured?
Those checks are useful during mutation/admin operations.
They do not necessarily belong in every public request.
Validate strongly when the asset is attached/published. Serve cheaply afterward unless a business rule requires real-time revocation.
How I audit media-related D1 usage
I use four traces.
1. Browser network trace
Count image/media requests for one page load.
2. Worker route trace
Identify which endpoint serves each asset.
3. D1 query trace
Find SQL statements executed by that media endpoint and measure rows/execution.
4. Cache trace
Check whether repeated requests are edge cache hits or repeatedly reaching application code.
This tells you whether the problem is:
- too many assets;
- missing edge cache;
- per-asset D1 lookup;
- expensive metadata SQL;
- authorization fan-out;
- cache headers that prevent reuse.
A simple before/after architecture
Before:
Browser -> /media/abc
-> Worker
-> D1: resolve abc -> r2/path.jpg
-> D1: permission/metadata
-> R2
-> response
For public media, after can look like:
Browser -> /images/content/abc.webp
-> edge cache
-> R2/origin only on miss
D1 remains involved when publishing/managing the asset, not necessarily when every visitor views it.
Do not confuse storage cost with row-read cost
Moving binary files to R2 does not automatically remove D1 from the serving path.
You can correctly store images in R2 and still burn D1 quota if every R2 access first requires a database lookup.
That is why the architectural question is:
Where is the mapping from public request to object key resolved?
If the answer is D1 every time, your object-storage traffic has a database multiplier.
This also improves resilience
Removing D1 from public media delivery has another benefit.
If D1 becomes constrained or temporarily unavailable, cached/public assets can still load.
That reduces blast radius.
A database incident should not necessarily turn every logo and image into a broken placeholder.
This is the same resilience principle behind cache-first public SSR: dependencies should fail according to the importance of the data they provide.
The practical fix list
If image routes are showing up in your D1 workload:
- classify assets as public or protected;
- map public assets to stable cacheable paths;
- keep binary data in R2/object storage;
- avoid D1 resolution on repeat public hits where possible;
- add appropriate edge/browser caching;
- make required D1 metadata lookups primary-key/index bounded;
- remove repeated public authorization joins;
- validate asset relationships during admin/publish mutations;
- measure browser subrequest count, not only page views;
- recheck D1 rows/hour after the media change.
The media system is part of your database architecture even when the files themselves live elsewhere.
If your D1 row-read numbers still look impossible after optimizing your obvious page queries, inspect your subresources. And if you want help tracing the whole request graph—from SSR to D1 to R2—see my MVP development service or contact me.



