InsightsArchitecture
Static, edge, or live: decided per path
A production storefront is prerendered to Cloudflare Pages, while one Kotlin origin handles every money path and the publishing workflows reduce content drift to a minimum with controls provided to operators.
- Author
- Alex GavriscoFounder & Principal Software Engineer
- Published
- Length
- 11 min read
basis: Draws on a recent commerce engagement.

One platform, several answers
“Static or dynamic?” is a framework question. It is usually answered once, at the framework level, and applied to every route. The system underneath has paths with different freshness, failure, and discoverability requirements, and one setting rarely serves all of them.
The storefront offers a small collection of limited-edition products. The priority is a focused shopping experience: fast delivery, careful presentation, and room for every product to tell its own story. The catalog does not need highly dynamic page generation, which makes prerendering a good fit and leaves the live commercial paths as the interesting part. The delivery mode was chosen per path, and the boundaries those choices created were then designed rather than discovered.
Two kinds of path
Stable presentation
The storefront is an Astro project with static output and no server adapter. Every route is prerendered by CI/CD workflows and deployed to Cloudflare Pages, then served through its global CDN. Interactive parts are small framework islands. If the commerce backend is down, the storefront retains its core shopping experience: the catalog and product pages still load, and customers can continue building a cart. Actions that require current commercial state, including checkout, fail explicitly until the service recovers.
Live commercial state
Price, stock, cart reconciliation, shipping and tax quotes, orders, and administration talk to scalable Kotlin/Spring Boot services deployed to AWS. Those paths fail explicitly, can be retried where that is safe, and leave state an operator can inspect.
| Path | Mode | Why |
|---|---|---|
| Home, story, shipping, legal | Prerendered | Pure content. Changes wait for a rebuild. |
| Collection and past releases | Prerendered page one, live cursor for the rest | First paint needs no request; page one can go stale between builds. |
| Product page | Prerendered, price and stock overwritten on mount | Search engines and slow networks get the page; buyers get today’s numbers. |
| Cart | Browser-local, reconciled against the API | No server session on a static site. Stale lines are repriced or removed explicitly. |
| Checkout and order status | Client-only islands, excluded from indexing | Totals are computed by the server for every quote. Purchase requires JavaScript. |
| Administration | Separate SPA against an authenticated API | Operators need the current state, not a published copy of it. |
Static does not mean frozen
A product page ships with the price and availability that were true at build time. On mount, an island calls the storefront API and overwrites both fields from a shared store. The sequence is deliberately short:
- The HTML carries the published price.
- The island fetches the current price and stock for that product.
- The fields are replaced. If the fetch fails, nothing happens and the published value stays.
That last rule is the important one. Every catalog read on the storefront swallows its errors, so a backend outage is handled gracefully. Checkout does the opposite: its errors are typed, and the request carries product identity and quantity only. The backend recomputes price, shipping, tax, and availability for every quote, so a stale page cannot set the total.
The static and live paths use the same product identifiers and classify failures in the same way, but they respond to those failures differently. If a catalog read fails, the published page stays usable. If a checkout call fails, the purchase stops with an explicit error. That difference is intentional. The Astro documentation on islands ↗ describes the mechanism; the decision about which fields an island owns is the part that has to be made per product.
Content drift, made visible
Pre-rendering removes work from the request path and introduces a question that a server-rendered site answers on every request:
Does the deployed storefront match the current catalog?
The backend answers it with a short, opaque content-version token computed from a canonical projection of the public products, their media, and their placements. The token is memoised on a cheap fingerprint of those tables, so most calls cost one small query. A catalog version represents more than the time of the last database change. Keeping the identifier opaque also allows the projection and API to evolve without coupling the administration console or storefront to a particular version format.
The Admin Console compares three values: the current token, the token recorded by the last successful build, and the token returned by the deployed site. Users with publishing permissions can see the storefront status there. It reveals when an automatic deployment has not completed and lets an authorized operator trigger one manually. Before another build is queued, the service rechecks the current version and any in-flight job to guard against duplicate or obsolete deployments.
- 01EditContent token changes
- 02DriftConsole shows “Changes pending”
- 03QueueBuild job queued with concurrency guards
- 04DeployCI reports back through a signed callback
- 05VerifyDeployed token matches current
Three attempts at triggering a build
The trigger design went through three iterations. The first used a Cloudflare deploy hook. It failed during the exploration phase: the build has to authenticate to a publishing API to fetch the catalog and prefetch images before Astro runs, and a hook would have put that token in a second environment. The second attempt was a proof of concept that used GitHub’s repository_dispatch event. That event runs the workflow from the default branch ↗, while the production environment only accepts deployments from the production branch, so every run died at the deploy step with a branch-not-allowed error.
The final version relies on workflow_dispatch, which can target the production ref, but keeps the third-party call out of the administration request path. A publication request queues a build job with concurrency guards in place. A scheduled dispatcher claims due jobs with the standard Postgres-as-a-queue ↗ pattern:
SELECT * FROM publish_jobs
WHERE state = 'queued'
AND claimed_at IS NULL
AND run_after <= now()
ORDER BY run_after
LIMIT 1
FOR UPDATE SKIP LOCKEDDispatch retries a bounded number of times with jittered backoff, then marks the job failed where an operator can see it. The workflow reports its result to a pre-signed, expiring callback URL. A job that never calls back is reaped after a timeout and the site stays marked out of sync. Requesting a build is never treated as proof that it finished.
Not every change waits for publication
Stock follows the live path. An inventory change hits the API at once, so a sold-out product stops being purchasable without a rebuild. Content and presentation changes accumulate until someone publishes. The operator guides give that distinction a two-column table, because the two paths look identical from the edit form.
Images: what pre-launch testing found
Prerendered pages get their images at build time: originals are prefetched and Astro produces the optimized AVIF and WebP variants as immutable assets. Cart thumbnails and “load more” results are different. They are requested at runtime with a width parameter, and the backend resizes the original on demand.
Pre-launch testing exposed a capacity risk in the subset of APIs that resize images eagerly. Producing a rendition from an original is CPU-intensive, so a burst of uncached requests could have saturated the service and delayed unrelated operations. A single mid-sized rendition took roughly 6 to 7.5 seconds of origin time on the deliberately small service instance, making the amplification risk clear before launch.
The edge-cache rule for image responses was part of a production-account provisioning checklist scheduled after testing. Running that checklist also caught an intervening change to the API’s path layout, so the cache rules were adjusted and verified before customer traffic reached the system.
Adding the cache rule took the cached response to roughly 0.14 seconds. That fixed repeat traffic and left the first request as expensive as before, so the contract changed as well. It follows the same logic as responsive-image guidance for CDNs: a few fixed widths cache far better than an unbounded set of sizes ↗.
A further improvement moves resizing out of the serving path altogether. Supported renditions can be generated lazily in response to authoring changes, then stored before the content is published. Once the required slots exist, storefront requests perform no eager resizing at all.
- A short list of supported widths chosen from the layout’s breakpoints, four or five in total, for example 320, 640, 960, and 1280 pixels.
- A request for any other width receives a 301 to the next supported slot, and the redirect itself is cacheable.
- Each original is decoded once per slot; the rendition is stored and served from then on.
- A small permit pool around resizing. When it is exhausted, the serving path answers 503 with Retry-After rather than queueing.
- Height, quality, and format parameters outside the contract are rejected with a 400 so the cache-key space stays small.
The width rule is four lines:
fun snapToSlot(requested: Int?): Int {
if (requested == null) return widths.last()
return widths.firstOrNull { it >= requested } ?: widths.last()
}Runtime image cost is now bounded by the number of images times the number of slots, and a bot probing arbitrary widths gets a cached redirect instead of a fresh decode. The permit pool is a plain bulkhead, the same shape resilience libraries ship as a semaphore bulkhead ↗: callers that would wait get an honest 503 instead.
measurement note: The 6 to 7.5 second figure represents the full request/response time against the origin during pre-launch testing. The origin’s time to first byte was lower, but the end-to-end duration better reflects the delay a user experiences. The 0.14 second figure is a Cloudflare cache hit after the rule was added, before the rendition work landed. Neither is a benchmark, but they’re useful for comparison.
What the split bought, and what it cost
Useful properties
- Visitors land on pre-rendered pages served from the CDN edge, so pages load as quickly as possible.
- A backend outage does not take down the storefront: customers can still browse products and build a cart.
- The backend recalculates price, stock, shipping, and tax for every quote and order.
- Page traffic stays at the edge; the origin is reserved for live commerce operations.
- The content version provides an immediate freshness check: whether the published storefront matches the current catalog.
Costs accepted
- Every content change needs to be published.
- Published content can become stale between builds, but drift is detected deterministically and kept to a minimum.
- Two image pipelines, build-time and runtime, to keep consistent.
- Publication workflows add necessary complexity to keep content in sync.
- Checkout and other interactive components require JavaScript.
To preserve the benefits of static generation, interactive islands use a lightweight client runtime built around fine-grained reactivity. It updates only the DOM nodes affected by state changes, keeping most of the page as static HTML and limiting shipped JavaScript to features that need it.
Those costs fit a reasonably sized, presentation-heavy catalog whose operators are willing to own publication as a workflow. They would be hard to defend for a catalog changing thousands of times an hour, a product with no browse-only experience worth protecting, or a team that wants content changes to be live the moment they are saved. In that case a server-rendered product page with a short cache is the simpler system, and the money paths would still be built the same way.
See the decision in the full engagement record →Read how the catalog states were modelled →
Working through a similar decision?
Describe the system, the constraint, and the options on the table. Alex will review the context and suggest a practical next step.