Headers
Every HTTP header GioJS sets on responses or reads from requests: cache status, request ids, caching, security, rate limits, prefetch and deployment skew.
$ curl -sI http://localhost:3000/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
x-gio-cache: hit; ttl=58
cache-control: public, max-age=0, s-maxage=58, stale-while-revalidate=540
etag: W/"223308d35592e3fbfac8c9538b61702b"
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-request-id: 65bde6d2-1ddc-4563-a81b-48e1ee673182
keep-alive: timeout=10That is a page with export const revalidate = 60, served from the page cache, with the default gio.toml. Header names are case-insensitive; GioJS sends them lowercase (HTTP/2 requires it).
Reference
Response headers at a glance
| Header | Sent on | Switch |
|---|---|---|
X-Gio-Cache | every response except the /_gio endpoints | - |
X-Request-Id | every response | [server] accept_request_id |
Cache-Control | pages, assets, public files, endpoints | [cache], [[headers]] |
ETag | cached pages, path-served CSS | [cache] etag |
X-Content-Type-Options, X-Frame-Options, Referrer-Policy | every response | [security] default_headers, [security.headers] |
Strict-Transport-Security | every response, with TLS or hsts | [security] hsts |
Content-Security-Policy | every response, when configured | [security] csp, csp_report_only |
X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After | paths a [[rate_limits]] rule covers | [[rate_limits]] |
X-Gio-Refused | a 429 or 413 sent before the body was read | - |
X-Gio-Action | the 409 for deployment skew | [server] skew_protection |
X-Gio-Redirect | a redirect answering a <GioForm> post | - |
Keep-Alive | HTTP/1.1 responses | [server] header_read_timeout_secs, idle_timeout_secs |
Content-Encoding, Vary | compressed responses, images | [compression] |
Request headers GioJS reads
| Header | What it does |
|---|---|
x-deployment-id | The client's build; a different one gets a 409. |
Purpose / Sec-Purpose: prefetch | Marks a prefetch, which spends the client's prefetch budget. |
x-gio-form: 1 | Marks a <GioForm> post: redirects come back as 204 + X-Gio-Redirect. |
X-Forwarded-For, -Proto, -Host, Forwarded | The client behind a proxy listed in [server] trusted_proxies. |
X-Request-Id | Adopted from a trusted proxy. |
Sec-Fetch-Site, Origin | CSRF, WebSocket and dev-endpoint checks. |
If-None-Match | A 304 when the cached page has not changed. |
Authorization, Cookie | Make a page personal: never stored, private. |
Accept-Language | Locale detection with [i18n]. |
Accept, Accept-Encoding | Image format and compression negotiation. |
Caching
X-Gio-Cache
Which cache tier answered and why, so cache behavior is visible from curl -I (gio cache explain <url> decodes it):
| Value | Meaning |
|---|---|
hit; ttl=<secs> | Served from the Rust page cache without Node; ttl is the seconds until the entry goes stale. |
stale; age=<secs>; revalidating | Served from the cache past its revalidate while one background render refreshes it; age is seconds since it was rendered. |
miss; stored | Rendered by the worker and stored; the next request is a hit. |
bypass | Rendered (or refused) and not stored: no revalidate, not GET/HEAD, personal, a route handler, the page cache off ([cache] enabled = false), a worker error (500, 504), a rule redirect, or one of the server's own refusals (a 400 for a malformed path, a CSRF 403, a rate-limit 429, a refused prefetch's 429, a skew 409). |
static | A file, answered without the page pipeline: public/ files, /_next/static assets, the CSS compiled at startup, and the self-hosted fonts under /_gio/fonts/. |
ppr; shell=stored | A PPR page rendered in full; its shell was captured and stored. |
ppr; shell=hit | The cached shell was sent at once and the holes streamed behind it. |
ppr; shell=stale; age=<secs>; revalidating | A stale shell sent while a background render refreshes it. |
HIT, MISS | On /_gio/image only: its own disk cache of encoded images. |
The other /_gio endpoints (everything but /_gio/fonts/ and /_gio/image) and 101 WebSocket upgrades carry no X-Gio-Cache. The cache label of gio_requests_total uses similar words with its own meaning: there, a render the worker answered is a miss whether it was stored or not (see the metrics).
Cache-Control
GioJS sets Cache-Control only where the response has none: a value from a route handler, getServerSideProps headers, a plugin or a [[headers]] / middleware.ts header rule always wins.
| Response | Cache-Control |
|---|---|
HTML page that may be shared (revalidate set, nothing personal) | public, max-age=0, s-maxage=<fresh>, stale-while-revalidate=<swr> |
Any other HTML page: personal, streamed, PPR, an error, guarded, a request with Authorization, a locale negotiated from headers, or CSP nonces on | private, no-cache |
Route handler (route.ts) | none: the handler decides |
/_next/static/*, /_gio/fonts/*.woff2, /_gio/image | public, max-age=31536000, immutable |
public/ files at the site root, path-served CSS, /_gio/fonts/fonts.css | public, max-age=0, must-revalidate |
A guarded file through /_gio/image | private, no-cache |
/_gio/revalidate, /_gio/devtools* JSON, a CSRF 403 | no-store |
For a shared page, <fresh> is what is left of its revalidate window and <swr> what is left of the stale window, which ends at revalidate times [cache] swr_multiplier (default 10). So a revalidate = 60 page rendered just now sends s-maxage=60, stale-while-revalidate=540. With swr_multiplier = 0 the stale-while-revalidate directive is left out. Browsers always revalidate (max-age=0), and a CDN in front caches for s-maxage: an on-demand purge does not reach a CDN. private, no-cache rather than no-store keeps the back/forward cache working.
ETag
A cached page carries a weak ETag (W/"..."), a hash of the stored page: one tag covers its gzip, Brotli and uncompressed bytes. A request whose If-None-Match matches gets 304 Not Modified with the same headers and no body. Pages get no ETag when [cache] etag = false, in development, with CSP nonces (every body is unique), or when the URL serves several audiences (the cases that make a page private above). Path-served CSS (/globals.css) carries a strong ETag and answers 304s too.
Request identity
X-Request-Id
Every response carries one, including cache hits, static files, redirects, errors and the /_gio endpoints. It is a UUID the server generates, set on the request before your code runs (req.requestId, ctx.requestId, socket.requestId), and on every server log line (request_id) and worker log line (requestId) for that request, error digests included.
An incoming X-Request-Id is kept only from a peer in [server] trusted_proxies, only when it matches ^[A-Za-z0-9._:-]{1,128}$, and only while [server] accept_request_id is true (the default). From anyone else it is replaced, so a client cannot inject ids into your logs.
X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, Forwarded
Read only from peers listed in [server] trusted_proxies (default: nobody), and only one family: X-Forwarded-* (the default) or RFC 7239 Forwarded with proxy_headers = "forwarded". The client address is found by walking the chain right to left past trusted hops, so nothing a client writes there is read. The result is req.ip, req.scheme and req.host, and what rate limits, prefetch budgets, [metrics] ip_allowlist, the CSRF host comparison and the logs use. X-Real-IP is never used for the address; it only marks a request as forwarded for the /_gio/metrics loopback check.
Security
X-Content-Type-Options, X-Frame-Options, Referrer-Policy
Stamped on every response, /_gio endpoints and errors included:
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-originA header the response already has wins, so a route handler or a [[headers]] rule can change one for some paths, and an empty value there removes it. [security.headers] changes or adds headers for every response ("" removes one), and [security] default_headers = false drops the three. GioJS also removes any X-Powered-By header, always.
Strict-Transport-Security
max-age=31536000 on every response when [server.tls] is on. Behind a TLS-terminating proxy set [security] hsts = true, a table (max_age, include_subdomains, preload) or a raw string. hsts = false turns it off even with TLS. It cannot be set through [security.headers].
Content-Security-Policy
Off by default. [security] csp and csp_report_only set Content-Security-Policy and Content-Security-Policy-Report-Only; a {nonce} in either becomes a fresh 192-bit nonce per response, which every framework inline script carries. With nonces on, pages are private, no-cache without an ETag, because each body is unique. See the Content Security Policy guide.
Sec-Fetch-Site and Origin
For POST, PUT, PATCH and DELETE ([security.csrf]) and WebSocket upgrades ([security.websocket]), the server reads Sec-Fetch-Site, or without it compares Origin with the host the client addressed. A cross-site request gets 403 with a text/plain body naming the setting to change; a request with neither header (curl, server-to-server webhooks) passes. The /_gio/devtools endpoints apply their own, stricter version.
Rate limits and refusals
X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After
On a request that a [[rate_limits]] rule covers, the response carries the size of the client's bucket, per_ip + burst, as X-RateLimit-Limit, and in X-RateLimit-Remaining the requests the client has left right now (never more than the limit), so Limit - Remaining is what it has used. A fresh client of a per_ip = 3 rule with the default burst = 20 sees 23 and 22. Over the limit, the server answers before any app code runs:
HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 20
x-ratelimit-limit: 23
x-ratelimit-remaining: 0
x-gio-refused: unread
{"error":"rate limit exceeded"}Retry-After is in seconds: window_seconds divided by per_ip, the time one request's worth of budget takes to come back (at least 1). POST /_gio/revalidate also sends it on the 429s that refuse a client after repeated wrong tokens.
X-Gio-Refused
x-gio-refused: unread marks a refusal the server sent before reading the request body: the rate-limit 429 and the 413 for a body over [server] max_body_bytes (default 2 MiB). <GioForm> resubmits natively only those (and the skew 409), never a 413 or 429 your action or route handler returned, because by then the action may have run.
Client router
x-deployment-id
The client router sends the deployment ID the page was rendered with on soft navigations, prefetches, router.refresh() and <GioForm> posts. When it differs from the server's, the server answers 409 Conflict before rendering anything.
X-Gio-Action
x-gio-action: hard-reload on that 409: the tab is running an older (or newer) build, so the router loads the URL in full instead of rendering it with stale code. A prefetch that gets it is dropped quietly; the click reloads. [server] skew_protection = false ignores x-deployment-id (no 409s). For your own fetch calls that send the header, isHardReloadResponse(res) from @gio.js/react recognizes this answer and handleHardReload() reloads the page.
Purpose: prefetch
<GioLink> and router.prefetch() send Purpose: prefetch and Sec-Purpose: prefetch. The server treats a request with either as a prefetch, and reads both as lists whose items may carry parameters, so a browser's speculation-rules prerender (Sec-Purpose: prefetch;prerender) counts too: it counts against the client's [prefetch] budget (max_concurrent, max_per_second), and over budget, or with [prefetch] enabled = false, it is refused with an empty 429 before rendering. The router treats a failed prefetch as "not prefetched"; the click still navigates. An admitted prefetch is an ordinary request otherwise: it is served from, and stored in, the page cache.
x-gio-form
<GioForm> sends x-gio-form: 1 with its POSTs once hydrated. A 301, 302 or 303 answering such a post (from the action, its getServerSideProps, a route handler or a plugin) becomes a 204 carrying the target in X-Gio-Redirect, with its cookies and other headers kept.
X-Gio-Redirect
The redirect target of that 204. The client router fetches a same-origin target itself and hands any other to the browser. fetch would otherwise follow the redirect on its own, and one leading to another site (a payment page, a sign-in) would fail the CORS check after the action had already run.307 and 308 are passed through, since they repeat the POST.
Content negotiation and connections
If-None-Match
Compared, weakly, with a cached page's ETag: a match (or *) on what would be a 200 becomes a 304.
Authorization and Cookie
A getServerSideProps that reads ctx.cookies or the cookie or authorization header renders per request and is never stored, whatever revalidate says, and neither is any response that sets a cookie. A request with Authorization gets private, no-cache and no ETag even for a shared page. The server also reads cookies itself: require_cookie and require_session guards, the gio_locale cookie for [i18n], and __gio_ppr_bypass, a short-lived cookie a PPR page sets to fetch a redirect or error the cached shell could not deliver. Each Set-Cookie a handler appends is sent as its own header.
Accept-Language
With [i18n], one of the sources detect_from lists (default path, accept-language, cookie, in that order). While detect_from lists accept-language or cookie, every page requested without a locale prefix is private, no-cache with no ETag - whether or not the request carried the header or the cookie, since one URL then serves several languages. URLs with a locale prefix stay shareable.
Accept and Accept-Encoding
/_gio/image picks AVIF or WebP from Accept and answers Vary: Accept. Accept-Encoding picks Brotli or gzip; see below.
Content-Encoding and Vary
With [compression] on (the default), responses of at least min_size_bytes (1024), and every streamed one except Server-Sent Events, are compressed with Brotli when the client accepts it (gzip with prefer_brotli = false), else gzip, and carry Vary: accept-encoding. Images are never recompressed. A 304 keeps the Vary of its 200.
Keep-Alive
HTTP/1.1 responses carry Keep-Alive: timeout=N, the seconds an idle connection stays open: the smaller of [server] header_read_timeout_secs (default 10) and idle_timeout_secs (60), leaving out one set to 0. With both at 0 the header is not sent. Clients that honor it stop reusing the socket first, instead of racing the server's close. Keep a proxy's upstream idle timeout below it.
The value is always the server's: a Keep-Alive or Connection header a page or route.ts sets is dropped, like the other connection-specific headers (Transfer-Encoding, Upgrade, TE, Trailer, Proxy-Connection), which describe one hop and are not allowed on HTTP/2.
Examples
Watch a page go from miss to hit
$ curl -sI http://localhost:3000/ | grep -i x-gio-cache
x-gio-cache: miss; stored
$ curl -sI http://localhost:3000/ | grep -i x-gio-cache
x-gio-cache: hit; ttl=60Revalidate with an ETag
ETAG=$(curl -sI http://localhost:3000/ | grep -i '^etag' | cut -d' ' -f2 | tr -d '\r')
curl -s -o /dev/null -w "%{http_code}\n" -H "If-None-Match: $ETAG" http://localhost:3000/
# 304Allow framing on one path
[[headers]]
path = "/embed/*rest"
headers = { "x-frame-options" = "", "content-security-policy" = "frame-ancestors https://partner.example" }The empty value removes the default X-Frame-Options on those paths only; everything else keeps SAMEORIGIN.
Set Cache-Control from a route handler
export function GET() {
return Response.json(
{ usd: 1, eur: 0.92 },
{ headers: { 'Cache-Control': 'public, max-age=60' } },
);
}Log the request id in a route handler
import type { GioRequest } from '@gio.js/core';
export function GET(req: GioRequest) {
return Response.json({ hello: 'world', requestId: req.requestId });
}Good to know
- Security headers are stamped when a response is served, never stored with a cached page, so a
gio.tomlchange reaches cached pages at the next restart. - A
Set-Cookieheader is never stored in the page cache, and a response that sets one is never shared between visitors. - Fixed, by design: request-id validation, the trusted-proxy rule for forwarding headers, removal of
X-Powered-By, and the cache bypass for personal renders (use PPR holes to cache the rest of such a page). - A static export has no server: none of these response headers are added. Set them on your static host.
- WebSocket handlers see a fixed subset of the upgrade request's headers in
socket.headers:cookie,authorization,user-agent,accept-language,originandx-request-id.
Related
- Endpoints - the
/_gioURLs and their responses - Browser and CDN caching and Observing the cache
- Default security headers and Content Security Policy
- Behind a reverse proxy and Request IDs
[[headers]],[[rate_limits]],[cache],[security]<GioLink>and<GioForm>
Version history
| Version | Changes |
|---|---|
v0.1.0-beta.8 | Default security headers, HSTS and CSP with nonces. X-Request-Id on every response, adopted only from trusted proxies. Pages send Cache-Control and a weak ETag with 304s. X-Gio-Refused, X-Gio-Redirect / x-gio-form, and Keep-Alive: timeout=N added. The router sends x-deployment-id on every navigation, prefetch, refresh and form post; [server] skew_protection turns the 409 off. Forwarding headers read only from trusted_proxies. X-RateLimit-Limit is the bucket size, per_ip + burst (it was per_ip, below what X-RateLimit-Remaining could show). The server's own refusals carry X-Gio-Cache: bypass instead of static, and self-hosted fonts carry static. Sec-Purpose: prefetch;prerender counts as a prefetch. Server-sent event streams no longer repeat Cache-Control, and no response forwards a Connection or Keep-Alive header the app set. |
v0.1.0-beta.7 | X-Gio-Cache labels PPR responses (ppr; shell=...). |
v0.1.0-beta.6 | X-Gio-Cache on every response; skew detection fires for soft navigations. |
v0.1.0-beta.1 | Version skew detection with x-deployment-id; rate limiting; prefetch budgets. |