Caching and Cache-Control
A deployed page can be cached in two places: the visitor’s browser and the CloudFront edge. blogwright controls both through the Cache-Control header that the builder writes on every file it uploads. The header comes from the cacheControl rules in your config. This page covers the defaults, how the two caches read the header, and how to write your own rules.
The defaults
Section titled “The defaults”When cacheControl is not set, every deploy applies these rules:
| Files | Cache-Control |
Why |
|---|---|---|
/_astro/**, /_app/immutable/** |
public, max-age=31536000, immutable |
Astro and SvelteKit put only content-hashed build output here. A changed file gets a new name, so a year is safe. |
| Everything else | public, max-age=0, s-maxage=86400, must-revalidate |
Browsers revalidate on every use, and CloudFront keeps the file for a day. |
Without any header, a browser falls back to heuristic freshness, typically 10% of the time since the file’s Last-Modified (RFC 9111 §4.2.2). A page left unchanged for 30 days can then be served from a visitor’s cache for about three days without asking. A CloudFront invalidation never reaches that copy, so the visitor sees the old page after your deploy. The catch-all rule stops that: max-age=0 makes the browser check with CloudFront each time. The check is cheap, because an unchanged page returns a 304 Not Modified from the edge.
The catch-all also sets s-maxage=86400, which keeps the edge lifetime at the 24 hours that the CachingOptimized policy already gave files with no header. The default therefore changes how browsers cache your site, but not how long CloudFront keeps it.
To write no Cache-Control at all, set "cacheControl": [].
Browser lifetime and edge lifetime
Section titled “Browser lifetime and edge lifetime”The header carries two lifetimes:
max-ageapplies to browsers. CloudFront also uses it whens-maxageis absent.s-maxageapplies only to shared caches such as CloudFront. Browsers ignore it.
Staging and production distributions use the AWS managed CachingOptimized cache policy: minimum TTL 1 second, default 86,400 seconds, maximum 31,536,000 seconds. CloudFront takes the edge TTL from s-maxage when it is present, otherwise from max-age, and clamps it to that range. It uses the 86,400-second default only when the file has neither (Expiration).
This is why a rule that only says public, max-age=0, must-revalidate is a trap. CloudFront clamps the edge TTL to 1 second, so almost every page view goes to S3. Always pair max-age=0 with an s-maxage.
How long can s-maxage be?
Section titled “How long can s-maxage be?”Every deploy, rollback and --refresh invalidates the paths it changed or deleted, so CloudFront drops an old copy as soon as a deploy replaces it. A long s-maxage is therefore safe while every deploy finishes. The risk is a deploy that stops after uploading but before invalidating, for example a CI job cancelled at that point. The next deploy sees the files as unchanged and does not invalidate them, so the edge serves the stale copy for up to s-maxage. Run blogwright deploy --refresh to invalidate everything if that happens.
s-maxage |
Edge copy lives up to | Trade-off |
|---|---|---|
86400 (default) |
1 day | Same as having no header. Each edge refetches each page daily. |
604800 |
1 week | Fewer S3 requests. A missed invalidation lasts up to a week. |
31536000 |
1 year | The edge keeps every page until a deploy evicts it. Run deploy --refresh after any interrupted deploy. |
Previews and server-rendered sites
Section titled “Previews and server-rendered sites”The shared preview distribution uses CachingDisabled, so the edge never caches and ignores s-maxage. The browser directives still apply, which is why one rule set works for both previews and production.
In an SSR environment, the rules apply to the ssr.staticDir files published to S3, which the ssr.staticPaths behaviors serve under CachingOptimized. Responses from the Web Function are not uploaded files, so the rules never touch them. The application sets its own headers.
Writing your own rules
Section titled “Writing your own rules”{ "cacheControl": [ // Content-addressed build output (Astro's hashed assets and fonts are both under /_astro/) { "match": ["/_app/immutable/**", "/_astro/**"], "value": "public, max-age=31536000, immutable" }, // Everything else: HTML, 404.html, sitemaps, robots.txt, service worker, manifests, unhashed public/ files { "match": "**", "value": "public, max-age=0, s-maxage=604800, must-revalidate" }, ],}- Rules are checked in order, and the first match wins. Put specific rules before general ones.
- A file that no rule matches gets no
Cache-Control. Your list replaces the defaults entirely, so end it with a**catch-all unless you want unmatched files left to heuristic freshness. - Patterns match the file’s URL path in the build output, such as
/index.htmlor/posts/hello/index.html. They never match the S3 key. A directory URL like/posts/hello/is served from itsindex.html, so match that file. Production (site/…) and previews (previews/<id>/site/…) therefore share one set of rules. - If your framework renames its hashed directory, update the rule. Astro’s
build.assetsand Vite’sbuild.assetsDirboth do this. A hashed rule only helps if it names the directory the build actually writes.
Pattern syntax
Section titled “Pattern syntax”| Syntax | Matches | Example |
|---|---|---|
/ |
path separator; every pattern starts with / or ** |
/robots.txt |
* |
any characters within one path segment | /*.html matches /about.html, not /blog/a.html |
? |
one character within one path segment | /img/logo-?.png |
** |
any number of whole segments; must be a whole segment | /_astro/**, **/*.html, /posts/**/index.html |
A leading **/ also matches top-level files, so **/*.html matches /index.html. Character classes ([ab]), braces ({a,b}) and backslashes are rejected. List alternatives as separate patterns instead.
Patterns match the decoded path, exactly as the file is named in the build output. Write non-ASCII names as themselves (/blog/café/*.html), never percent-encoded: a pattern containing an escape such as %20 or %C3%A9 is rejected. A ? matches one character, including one outside the Basic Multilingual Plane such as an emoji.
Validation
Section titled “Validation”The config fails to load, naming the rule and pattern, when:
cacheControlis not an array, a rule is not an object, a rule has a key other thanmatchandvalue, ormatchis empty.valueis not a validCache-Controlvalue: comma-separated directives such asmax-age=3600orprivate="set-cookie".- A pattern can never match. That covers a pattern that does not start with
/or**, one that ends in/or has an empty segment, a.or..segment, a percent-encoded character, a**inside a segment, and a pattern an earlier rule already covers. A pattern counts as covered when it repeats an earlier one, follows a match-everything pattern (**or**/*), sits under an earlier/<dir>/**(or/<dir>/**/*), or is a literal path an earlier pattern matches. Overlaps between two wildcard patterns outside these shapes, such as**/*.htmlfollowed by/blog/*.html, are not detected. - A pattern is redundant within its own rule: it repeats, or is covered by, an earlier pattern in the same
matchlist, by the same shapes as above.
Error responses and the 404 page
Section titled “Error responses and the 404 page”On staging and production, S3 answers a request for a missing key with 403, because CloudFront may read objects but not list the bucket. CloudFront maps that 403, and any 404, to your /404.html with status 404, or to /index.html with 200 in spa mode. Two caches are involved:
- The error response for that path. CloudFront caches it for the error caching minimum TTL, which blogwright sets to 10 seconds (CloudFront’s default). S3’s error carries no
Cache-Control, so your rules cannot lengthen it. When a deploy later publishes a page at that path, the path counts as changed and is invalidated anyway. - The
/404.htmlfile itself. CloudFront fetches it like any other file, so it gets theCache-Controlof the rule that matches/404.html. CloudFront serves this page for every missing path. Keep it, and/index.htmlinspamode, atmax-age=0so a browser never holds on to a “not found” for a page you are about to publish. The default catch-all already does this.
The cache policy (CachingOptimized) and the 10-second error TTL are not configurable today.
When the rules change
Section titled “When the rules change”S3 writes Cache-Control only when a file is uploaded, and a deploy skips unchanged files. To keep live files current, blogwright keeps a record per target at build/cache-control/<target>.json (site.json, or preview-<id>.json for a preview) of the rules its files were written with. When the next deploy, rollback or preview deploy to that target finds different rules, it re-uploads every file, as --refresh would, and logs:
cacheControl rules differ from the ones this target's files were written with - re-uploading every file so unchanged ones get the new Cache-Control too (an implicit --refresh)Every path then counts as changed, so the invalidation covers the whole site (or /* past invalidationMaxPaths). A target with no record yet gets the same treatment, logged as no Cache-Control record for this target yet. That happens once on the first deploy after upgrading to a blogwright version with cacheControl, so that existing files get the defaults, and on the first deploy of a new target, where it costs nothing extra because every file is new.
The record only claims what a build proved:
- The builder confirms the rules. Its changed-paths manifest reports the rules it wrote, and only then is the record updated to them. A builder image older than
cacheControlwrites no header and reports nothing. That can happen on arollback, which runs on whatever builder image is live without rebuilding it. The record is then marked unconfirmed, with a warning, and the next build re-applies the rules. - A build that changes the rules marks the record unconfirmed before it starts. A build that then fails or is cancelled partway may have rewritten only some files. The next build re-applies the rules in full, even if you have reverted the config in the meantime.
A record that is not valid JSON or holds rules that no longer validate is ignored with a warning, and the next build re-applies the rules.
A release built with stage is its own target (release-<hash>.json), and promote carries its headers into site/ unchanged. It copies every file - and so invalidates every path - when the staged rules are not known to match the ones site/ was written with: they differ, either record is unconfirmed, the staged record is missing, or site/ has no record while the staged rules are not empty. It marks site/’s record unconfirmed before the first copy, so a promotion that stops partway is re-applied by the next build, and once the copies are done site/’s record holds the staged rules - see Release gating.