GioJSdocs
On this page

Headers

Every HTTP header GioJS sets on responses or reads from requests: cache status, request ids, caching, security, rate limits, prefetch and deployment skew.

bash
$ 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=10

That 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

HeaderSent onSwitch
X-Gio-Cacheevery response except the /_gio endpoints-
X-Request-Idevery response[server] accept_request_id
Cache-Controlpages, assets, public files, endpoints[cache], [[headers]]
ETagcached pages, path-served CSS[cache] etag
X-Content-Type-Options, X-Frame-Options, Referrer-Policyevery response[security] default_headers, [security.headers]
Strict-Transport-Securityevery response, with TLS or hsts[security] hsts
Content-Security-Policyevery response, when configured[security] csp, csp_report_only
X-RateLimit-Limit, X-RateLimit-Remaining, Retry-Afterpaths a [[rate_limits]] rule covers[[rate_limits]]
X-Gio-Refuseda 429 or 413 sent before the body was read-
X-Gio-Actionthe 409 for deployment skew[server] skew_protection
X-Gio-Redirecta redirect answering a <GioForm> post-
Keep-AliveHTTP/1.1 responses[server] header_read_timeout_secs, idle_timeout_secs
Content-Encoding, Varycompressed responses, images[compression]

Request headers GioJS reads

HeaderWhat it does
x-deployment-idThe client's build; a different one gets a 409.
Purpose / Sec-Purpose: prefetchMarks a prefetch, which spends the client's prefetch budget.
x-gio-form: 1Marks a <GioForm> post: redirects come back as 204 + X-Gio-Redirect.
X-Forwarded-For, -Proto, -Host, ForwardedThe client behind a proxy listed in [server] trusted_proxies.
X-Request-IdAdopted from a trusted proxy.
Sec-Fetch-Site, OriginCSRF, WebSocket and dev-endpoint checks.
If-None-MatchA 304 when the cached page has not changed.
Authorization, CookieMake a page personal: never stored, private.
Accept-LanguageLocale detection with [i18n].
Accept, Accept-EncodingImage 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):

ValueMeaning
hit; ttl=<secs>Served from the Rust page cache without Node; ttl is the seconds until the entry goes stale.
stale; age=<secs>; revalidatingServed from the cache past its revalidate while one background render refreshes it; age is seconds since it was rendered.
miss; storedRendered by the worker and stored; the next request is a hit.
bypassRendered (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).
staticA 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=storedA PPR page rendered in full; its shell was captured and stored.
ppr; shell=hitThe cached shell was sent at once and the holes streamed behind it.
ppr; shell=stale; age=<secs>; revalidatingA stale shell sent while a background render refreshes it.
HIT, MISSOn /_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.

ResponseCache-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 onprivate, no-cache
Route handler (route.ts)none: the handler decides
/_next/static/*, /_gio/fonts/*.woff2, /_gio/imagepublic, max-age=31536000, immutable
public/ files at the site root, path-served CSS, /_gio/fonts/fonts.csspublic, max-age=0, must-revalidate
A guarded file through /_gio/imageprivate, no-cache
/_gio/revalidate, /_gio/devtools* JSON, a CSRF 403no-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:

text
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin

A 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:

text
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.

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.

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

bash
$ 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=60

Revalidate with an ETag

bash
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/
# 304

Allow framing on one path

gio.toml
[[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

app/api/prices/route.ts
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

app/api/hello/route.ts
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.toml change reaches cached pages at the next restart.
  • A Set-Cookie header 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, origin and x-request-id.

Version history

VersionChanges
v0.1.0-beta.8Default 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.7X-Gio-Cache labels PPR responses (ppr; shell=...).
v0.1.0-beta.6X-Gio-Cache on every response; skew detection fires for soft navigations.
v0.1.0-beta.1Version skew detection with x-deployment-id; rate limiting; prefetch budgets.