# Response cache

URL: https://scalws.com/docs/guide/caching/
Source: `docs/guide/caching.md` in the ScalWS repository

ScalWS has a built-in HTTP response cache that sits in front of the application runtimes
(PHP, Node.js, Python, containers, proxies). It is designed to be safe by default: it only stores
responses that the application explicitly marks as cacheable, or that you opt in with a
default lifetime, and it never stores responses that set cookies or are marked private.

Design records: [ADR-0012](https://scalws.com/docs/adr/0012-response-cache/) (memory cache) and
[ADR-0024](https://scalws.com/docs/adr/0024-cache-disk-tier/) (disk tier).

## Turning it on

The cache has two levels of configuration:

* [`server.cache`](https://scalws.com/docs/reference/server/#server.cache): the server-wide memory budget and the optional disk tier. It always
  exists (with defaults), but caches nothing on its own.
* [`cache:`](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache) on an application, opts that application in and sets its rules.

```yaml
server:
  cache:
    memory: 256MiB

tenants:
  - name: blog
    applications:
      - name: blog
        domains: [blog.example.com]
        root: /srv/www/blog
        profile: wordpress
        cache:
          default_ttl: 5m
          stale_while_revalidate: 1m
```

An application without a `cache:` block (or with `enabled: false`) never uses the cache.
A [profile](https://scalws.com/docs/guide/applications/) on its own does not enable caching either; it only adds bypass
rules once you do.

Two kinds of routes skip the cache even in an application with `cache:`:

* `static` routes, unless `default_ttl` is set. Static files are already served from the
  page cache and the open file cache; with `default_ttl` they are stored like any other
  `200` response without caching headers.
* `respond` routes (fixed answers), which are cheaper than a cache lookup.

## `server.cache`

Every key of `server.cache`, with its syntax, default and reload behaviour:
[reference](https://scalws.com/docs/reference/server-cache/).
[`memory`](https://scalws.com/docs/reference/server-cache/#server.cache.memory) counts body plus headers plus
a small per-entry overhead.
[`max_entry_size`](https://scalws.com/docs/reference/server-cache/#server.cache.max_entry_size) must be
greater than zero and not larger than `memory`.

The memory cache evicts with TinyLFU admission weighted by size, so rarely requested large
responses do not push out popular ones.

### Disk tier

```yaml
server:
  cache:
    memory: 256MiB
    disk:
      path: /var/lib/scalws/cache
      size: 20GiB
      max_entry_size: 256MiB     # optional
```

Every key of [`server.cache.disk`](https://scalws.com/docs/reference/server-cache/#server.cache.disk):
[reference](https://scalws.com/docs/reference/server-cache/). `path` and `size` are required; `path` must be
absolute; when the tier reaches [`size`](https://scalws.com/docs/reference/server-cache/#server.cache.disk.size),
the least recently used files are removed first; `max_entry_size` may be at most `size`.

The disk tier receives entries that the memory cache evicts for lack of space and
responses too large for memory (up to `disk.max_entry_size`). On a memory miss it is
consulted; a disk hit that fits is copied back into memory. The disk tier survives
restarts: at start the directory is indexed and expired or partial files are deleted.

Writes are asynchronous and never slow down a request: when the write queue is full, the
write is skipped. A response written a moment ago may therefore still be a miss.

If the directory cannot be used, the server does not start. With the packaged systemd
unit only `/var/lib/scalws` is writable, so put the cache below it. Cache files contain
response headers and bodies: keep the directory private to the `scalws` user.

### Changing these settings

`server.cache` (memory, entry size, disk) cannot change on a reload; the reload is refused
with "requires a restart". The per-application `cache:` settings below change on reload.

## Per-application `cache:`

Every key of an application's `cache:`, with its syntax, default and reload behaviour:
[reference](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache). In short:

* [`enabled: false`](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache.enabled) switches the cache off for this application while
  keeping the block.
* [`default_ttl`](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache.default_ttl) applies to `200` responses that carry **no** caching
  headers at all (no `Cache-Control`, no `Expires`); it must be greater than zero.
* [`stale_while_revalidate`](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache.stale_while_revalidate) keeps serving an expired entry
  while one background request refreshes it; a response's own `stale-while-revalidate`
  directive takes precedence.
* The bypass lists are added to the profile's.

Durations are written like `30s`, `5m`, `1h`, `1d`.

Matching rules for the bypass lists:

* [`bypass_paths`](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache.bypass_paths): plain prefixes (no `*`), compared case-insensitively against the start
  of the normalised path. `/wp-admin` also matches `/wp-admin/edit.php`, and
  `/wp-administrator`, since the match is not segment-aligned. Entries must start with `/`
  and must not contain `?` or `#`. The rule is checked against both the URL the client
  asked for and, after an `.htaccess` rewrite, the rewritten one.
* [`bypass_cookies`](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache.bypass_cookies): a request is bypassed when any `Cookie` name starts with one of the
  prefixes (case-sensitive), e.g. `wordpress_logged_in_` matches
  `wordpress_logged_in_3f2a…`.
* [`bypass_query`](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache.bypass_query): a request is bypassed when its query string contains a parameter with
  exactly this name, e.g. `add-to-cart` matches `?add-to-cart=42`.

## What is cached

### Requests that never use the cache

These are passed straight to the application and get `X-Cache: BYPASS`:

* methods other than `GET` and `HEAD`;
* requests with an `Authorization` header;
* requests on a route with `basic_auth` or an `allow`/`deny` list (its responses are not
  stored either, so another route on the same URL never serves them);
* requests with a `Range` header;
* requests with `Cache-Control: no-cache` or `no-store`, or `Pragma: no-cache`;
* requests matching the application's bypass rules (profile plus configuration).

A request carrying cookies that match **no** bypass prefix can be answered from the cache.
If your application personalises responses based on a cookie, list that cookie in
`bypass_cookies`, send `Cache-Control: private`, or send `Vary: Cookie`.

### Responses that are stored

A response is stored only when all of these hold:

* the status is one of `200, 203, 204, 300, 301, 308, 404, 405, 410, 414, 501`;
* it has no `Set-Cookie` header;
* its `Cache-Control` does not contain `private`, `no-store` or `no-cache`;
* it does not have `Vary: *`;
* it has a lifetime: `s-maxage`, otherwise `max-age`, otherwise `Expires` (relative to
  `Date`), or, for a `200` without any `Cache-Control` or `Expires` header,
  `default_ttl`. A lifetime of zero is not stored. There is no heuristic freshness;
* the body was received completely and fits the size limits.

`public` is not required. If a response cannot be stored, that URL skips the cache for the
next 10 seconds ("hit-for-pass"), so uncacheable pages do not pay for lookups.

### Keys and variants

* The cache key is the request host plus the path and query exactly as the client sent
  them. `?a=1&b=2` and `?b=2&a=1` are different entries.
* `http://` and `https://` responses are kept apart, and so are responses of different
  applications (a host moved to another application never gets the previous owner's
  entries).
* `Vary` is honoured: the request header values named in `Vary` select the variant.
  At most 8 variants are kept per URL; the oldest is dropped first.

### Freshness, stale content and refreshes

* A cached response is served with `Age` and `X-Cache: HIT` while it is fresh.
* After it expires it may be served as `X-Cache: STALE` for the stale-while-revalidate
  window (from the response's `stale-while-revalidate=N` directive, otherwise the
  application's `stale_while_revalidate`). One background request per URL refreshes it.
  `must-revalidate` or `proxy-revalidate` in the response disables stale serving.
* Concurrent misses for the same URL are coalesced: one request goes to the application,
  the others wait for it (up to 5 seconds) and are then answered from the cache.
* `HEAD` requests are answered from a stored `GET` response; a `HEAD` miss is not stored.
* A successful (`2xx` or `3xx`) `POST`, `PUT`, `PATCH` or `DELETE` removes the cached
  entries of exactly that URL.

### Response headers

Responses that went through the cache carry `X-Cache`:

| Value | Meaning |
|---|---|
| `HIT` | Served from the cache (with `Age`). |
| `STALE` | Served from the cache after expiry, while a refresh runs. |
| `MISS` | Fetched from the application (and stored if storable). |
| `BYPASS` | The request did not use the cache (see above). |

Requests that do not go through the cache stage at all (applications without `cache:`,
and the routes listed under [Turning it on](#turning-it-on)) get no `X-Cache` header.

A quick check:

```sh
curl -sI https://blog.example.com/ | grep -iE '^(x-cache|age|cache-control)'
```

## Purging

Purges remove entries from memory and from the disk tier.

```sh
scalwsctl purge --host blog.example.com --path /2026/10/hello-world/   # one URL
scalwsctl purge --host blog.example.com --prefix /2026/                # everything below
scalwsctl purge --tag product-42                                       # by Cache-Tag
scalwsctl purge --all                                                  # everything
```

| Options | Removes |
|---|---|
| `--host H --path P` | The entry for exactly that path and query (all variants). |
| `--host H --prefix P` | Every entry of host `H` whose path and query start with `P`. |
| `--tag T` | Every entry whose response carried tag `T` in a `Cache-Tag` header (comma- or space-separated list). |
| `--all` | The whole cache. |

Exactly one of these forms is accepted. `--path` and `--prefix` are matched against the
URL as the client sent it (after an internal `.htaccess` rewrite, the client's URL is still
the key).

Tag purges let an application invalidate related pages without knowing their URLs: send
`Cache-Tag: product-42, category-7` with the response, then purge by tag when the product
changes.

The same operation is available on the admin socket as `POST /v1/cache/purge` with the
query parameters `host` + `path`, `host` + `prefix`, `tag`, or `all=1` (see
[cli.md](https://scalws.com/docs/guide/cli/) and [OPERATIONS.md](https://scalws.com/docs/operations/)).

## WordPress and other profiles

Profiles add bypass rules for the admin area and logged-in users. With `profile: wordpress`
the application bypasses the cache for:

* paths `/wp-admin`, `/wp-login.php`, `/wp-json`, `/xmlrpc.php`, `/wp-cron.php`;
* cookies starting with `wordpress_logged_in_`, `wp-postpass_`, `comment_author_`,
  `wordpress_sec_`.

`profile: woocommerce` adds `/cart`, `/checkout`, `/my-account`, `/wc-api`, the query
parameters `wc-ajax` and `add-to-cart`, and the cookies `woocommerce_items_in_cart`,
`woocommerce_cart_hash`, `wp_woocommerce_session_`. Other profiles (Django, Laravel,
Flask, FastAPI, Next.js) bring their own session cookies and admin paths.

Your own `bypass_*` entries are added to the profile's; they never replace them. If your
site uses a custom login URL or a membership plugin with its own cookie, add it:

```yaml
        profile: wordpress
        cache:
          default_ttl: 10m
          stale_while_revalidate: 1m
          bypass_paths: [/members]
          bypass_cookies: [pmpro_]
```

WordPress pages usually carry no caching headers, so nothing is stored until you set
`default_ttl`. Pages that start a PHP session or set cookies are never stored. After
publishing content, purge the affected URLs (or `--prefix /`): the cache does not know
when WordPress changes a page.

## Several instances

Under scalws-controller every `scalwsd` instance has its own cache:

* Memory caches are per instance; each fills on its own, so the hit ratio is lower right
  after a scale-out or a restart.
* With a disk tier, each instance uses its own subdirectory `<path>/<instance-id>`.
  Sharing one directory between instances is not supported.
* `scalwsctl purge` talks to one admin socket. To purge every instance, use the
  controller, which forwards the purge to each serving instance and removes the disk
  caches of instances that are not running:

  ```sh
  scalws-controller purge 'host=blog.example.com&prefix=/2026/'
  scalws-controller purge 'tag=product-42'
  scalws-controller purge 'all=1'
  ```

  The ScalWS Console's cache purge goes through the controller as well.

## Metrics

| Metric | Meaning |
|---|---|
| `scalws_cache_lookups_total{tenant,app,result}` | `hit`, `stale`, `miss`, `bypass` per application. |
| `scalws_cache_entries`, `scalws_cache_bytes` | Memory cache size. |
| `scalws_cache_disk_entries`, `scalws_cache_disk_bytes` | Disk tier size. |

## See also

* [applications.md](https://scalws.com/docs/guide/applications/): profiles and runtimes.
* [configuration.md](https://scalws.com/docs/guide/configuration/): sizes, durations, reload rules.
* [troubleshooting.md](https://scalws.com/docs/guide/troubleshooting/): when pages are not cached or stay stale.
* [THREAT_MODEL.md](https://scalws.com/docs/threat-model/): cache poisoning and personalised content.
