Static read models
Render the views readers need ahead of time, as JSON, HTML or Parquet files in object storage behind a CDN, so a read is a file fetch with no database on the path. Also called ahead-of-time view generation or precomputed read models.
3 technologies on this page ↓How it works
It is the read side of CQRS taken to the extreme. Writes still go to a command side that checks the rules and saves the change in a database or event store. Each change, or a schedule, triggers a projector: a job that works out which views the change affects, renders each one in full (products/42.json, categories/kettles.json, a dashboard’s daily-orders.csv) and writes it to object storage. A CDN serves those files, so readers never touch the write database, and the number of readers costs bandwidth, not queries. The price is staleness and fan-out. A reader sees the new data only after the projector has run, the file is written and the CDN copy has expired or been invalidated: seconds to minutes with events, up to a whole interval with a schedule. One write can touch many views (a price change updates the product page, its category listing and every search page that shows it), and each one is a full file rewrite. Write each file atomically, as one whole PUT or a write to a temporary name and a rename, so readers see the old version or the new one, never half of each. Then expire the cached copy with a short Cache-Control: max-age, an explicit invalidation, or a new versioned file name. The same idea appears in three forms: event-driven projections that write files, data apps that run their queries at build time and ship a static site, and pre-aggregated rollups refreshed before anyone asks.
A railway station’s printed timetable. Planners change the schedule in their own system, then print new posters and paste them up on every platform. Thousands of travellers read the posters without asking anyone, but a change made this morning only reaches them once someone has reprinted and replaced every poster it affects.
Reach for it when
- Reads outnumber writes by orders of magnitude and the same few page shapes are read again and again: product pages, public profiles, status pages, published reports
- Readers can tolerate data that is seconds or minutes old
- You want reads to keep working when the write database is slow, down or being migrated
- Dashboards and reports whose data changes daily or hourly, built on a schedule
Think twice when
- Each user sees their own data or queries with ad hoc filters: the number of possible views explodes, so serve those from a database or an API
- Users must see their own change immediately (strong read-your-writes), such as a balance right after a transfer
- One write fans out to thousands of views, or the data changes faster than you can rewrite and invalidate the files
A real example
A shop has 40,000 products, about 200 price changes an hour and 3 million product and category page views a day. A ChangePrice command updates PostgreSQL and records a PriceChanged event. A projector rewrites products/p1.json and the listing file for its category in S3, then invalidates both paths in CloudFront. That is 400 file writes an hour against 125,000 page views an hour, almost all of them CDN hits that never reach S3, let alone the database. A shopper can see the old price for up to a minute; the checkout reads the current price from the write side, so nobody is charged the stale one.
Kinds of static read models
Specific products, and what each does differently.
Views as static files →
An event-driven projector renders each affected view as a JSON or HTML file in object storage, and a CDN serves it, so the read path has no database at all.
Data apps built ahead of time →
A static site generator for dashboards and reports: queries run once during the build, their results are saved as data files next to the pages, and the site is redeployed on a schedule.
Pre-aggregated rollups →
Summary tables (revenue by day and region, say) that a database or a semantic layer builds and refreshes ahead of queries, then answers matching queries from instead of the raw rows.
| Data apps built ahead of time | Open source | Evidence Technologies, Inc. | MIT | 2026-10-09 | |
| Data apps built ahead of time | Open source | Observable, Inc. | ISC | 2026-10-09 | |
| Pre-aggregated rollups | Open source | Cube Dev, Inc. | Apache 2.0 (Cube Core backend), MIT (client libraries) | 2026-10-09 |
When to regenerate the views#
Every static read model needs a trigger. The choice sets how stale a reader’s view can be and how much rendering you pay for.
| Trigger | How it works | Worst-case staleness | Cost driver |
|---|---|---|---|
| On each change | An event per write starts a projector that rewrites only the views that change touches | Event lag plus render and CDN expiry, usually seconds | Fan-out: views touched per write, times writes |
| On a schedule | A job rebuilds every view (or every changed partition) every hour or day, as a static site build or a rollup refresh | The whole interval | Size of the full rebuild, even when little changed |
| On read, in the background | The first request after a view expires gets the old file and triggers a re-render, as with stale-while-revalidate or Next.js incremental static regeneration | One expiry period plus one render | Popular views only; rarely read ones may stay old |
Getting new files past the cache#
Writing the new file is half the job: copies at the CDN edge and in browsers keep serving the old one until they expire or are replaced.
Short max-age
simplestServe views with Cache-Control: max-age=60 (or s-maxage for the CDN only). Staleness is capped at a minute and nothing needs purging, but the CDN asks the origin again every minute for every popular file.
Invalidate paths
per changed fileAfter writing, the projector asks the CDN to drop the changed paths. On CloudFront the first 1,000 invalidation paths a month are free and each further path is charged, so a write that fans out to many files gets expensive. A wildcard such as /categories/* counts as one path.
Invalidate by tag
for fan-outTag each file with what it shows (product:p1) when you write it, then invalidate the tag: every cached file carrying it is dropped, whatever its path. CloudFront supports this with a # prefix in an invalidation request.
Versioned file names
no purge at allWrite daily-orders.2444bc86.csv instead of overwriting daily-orders.csv, and point a small, short-lived index or page at the new name. Cached copies of old names stay correct forever, and rolling back is a pointer change. Static site generators that hash file contents work this way.
Fan-out is the real cost#
A database view is computed when it is read; a static view is computed for every write that could change it, whether or not anyone reads it. If a write touches one product page, that is cheap. If it touches the product page, two category listings, 30 search result pages and the home page, every write becomes 34 renders, uploads and invalidations. Keep view shapes few and coarse, render pages that list many items from one shared file rather than embedding copies, and leave per-user and ad hoc queries to a database.
The same arithmetic limits build-time data apps. A legacy Evidence build pre-rendered every permutation of every parameterized page, and its docs suggested switching to single-page-app mode when a project had more than 1,000 pages because builds got slow. Microsoft’s Materialized View pattern makes the matching warning for views in general: when many queries each need their own view, the storage needed and its cost can become unacceptable.
In code · python
import json, os, tempfile
from pathlib import Path
ROOT = Path(tempfile.mkdtemp()) # stands in for an S3 bucket behind a CDN
def publish(key: str, view: dict) -> None:
path = ROOT / key
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(view))
os.replace(tmp, path) # readers see the old file or the new one, never half
# Write side: the source of truth (a database in real life)
products = {"p1": {"name": "Kettle", "price": 30, "category": "kitchen"},
"p2": {"name": "Toaster", "price": 45, "category": "kitchen"}}
# Projector: one change re-renders every view that shows the product
def on_price_changed(product_id: str, price: int) -> list[str]:
products[product_id]["price"] = price
p = products[product_id]
publish(f"products/{product_id}.json", p)
listing = [{"id": i, **q} for i, q in products.items() if q["category"] == p["category"]]
publish(f"categories/{p['category']}.json", {"items": listing})
return [f"products/{product_id}.json", f"categories/{p['category']}.json"]
print(on_price_changed("p1", 28)) # ['products/p1.json', 'categories/kitchen.json']
# Read side: a plain file fetch, no database involved
print(json.loads((ROOT / "categories/kitchen.json").read_text())["items"][0])
# {'id': 'p1', 'name': 'Kettle', 'price': 28, 'category': 'kitchen'}Often confused with
Where it came from2008–2025
Rendering pages ahead of time is older than CQRS. When Tom Preston-Werner announced Jekyll in November 2008, Preston-Werner wanted a personal blog as a static site rather than “a dynamic site that required ongoing maintenance”: templates and posts go in, a complete static website comes out, ready for any web server. Rebuilding on change replaced querying on every request.
In February 2009 Martin Fowler described a pattern under the name Eager Read Derivation, from a talk by Greg Young at QCon San Francisco. Reads didn’t touch the main database at all. The domain model ran the derivation logic for every screen up front and sent messages that kept reporting databases, structured the same way as the read requests, up to date. Fowler pointed out the inconsistency window (click faster than the messages and you don’t see your own update) and noted that most high-volume websites already populated databases with derived data. Young’s CQRS Documents (2010) gave the reason it pays: the query side of most web systems handles two or more orders of magnitude more transactions than the command side.
The 2020s brought the idea to files and data apps. Next.js 9.5 made incremental static regeneration stable in July 2020, re-rendering static pages in the background as traffic arrives and keeping the old page if regeneration fails. Evidence launched in 2021 as a static site generator for SQL analysts, and Observable Framework followed in February 2024 with data loaders that snapshot data at build time. Evidence also shows the limit: in 2025 its new Studio product moved queries to the server, and its migration guide sets the old build per change and roughly 2 million rows against queries on page load over hundreds of millions.
Why it’s built this way
- Derive on write, not on read
- Reads vastly outnumber writes, so computing each view once per change is cheaper than once per request, and the read side needs no domain logic at all.
- Plain files instead of a read database
- Static files can be served by any web server or CDN, need no per-request compute and give viewers no route to the source data. Observable’s docs list speed, smaller payloads and security as the reasons for build-time snapshots.
- Publish whole versions, never patch in place
- A reader must never see half a view. Atomic object writes, versioned file names and builds that keep the last good version live when a query fails all serve the same goal.
- Bound the staleness instead of removing it
- Asynchronous derivation always has an inconsistency window. Designs make it explicit with an event lag budget, a
max-age, a revalidate interval or a rebuild schedule, and keep anything that must be current, such as a checkout price, on the write side.
Timeline
- 2008
Tom Preston-Werner announces Jekyll, a static site generator for a personal blog
- 2009
Martin Fowler describes Eager Read Derivation from Greg Young’s QCon talk
- 2010
Greg Young’s CQRS Documents set out denormalised, eventually consistent query sides
- 2020
Next.js 9.5 makes incremental static regeneration stable
- 2021
Evidence launches as a static site generator for SQL analysts
- 2024
Observable Framework 1.0 ships data loaders that snapshot data at build time
- 2025
Evidence Studio moves queries server-side, leaving static builds as the legacy mode
- Tom Preston-Werner: Blogging Like a Hacker (Nov 2008)
- Martin Fowler: Eager Read Derivation (bliki, Feb 2009)
- Greg Young: CQRS Documents (2010)
- Next.js blog: Next.js 9.5 (stable incremental static regeneration, July 2020)
- Hacker News: Launch HN: Evidence (YC S21) (Aug 2021)
- GitHub: Observable Framework v1.0.0 release (Feb 2024)
- Evidence blog: Introducing Evidence Studio (June 2025; server-side queries)
- Observable Framework docs: What is Framework? (static-site architecture)
Sources15
- Martin Fowler: CQRS (bliki, Jul 2011)
- Martin Fowler: Eager Read Derivation (bliki, Feb 2009)
- Greg Young: CQRS Documents (2010)
- Azure Architecture Center: CQRS pattern
- Azure Architecture Center: Materialized View pattern
- Amazon S3 User Guide: data consistency model
- Amazon CloudFront Developer Guide: Manage how long content stays in the cache (max-age, s-maxage)
- Amazon CloudFront Developer Guide: Invalidate files
- Amazon CloudFront Developer Guide: Invalidating content by cache tags
- Amazon CloudFront Developer Guide: Pay for file invalidation
- Next.js blog: Next.js 9.5 (incremental static regeneration)
- Observable Framework docs: Data loaders
- Observable Framework docs: Files (content hashes)
- Evidence legacy docs: Deployment overview
- Cube docs: Using pre-aggregations