<!--
  Caching Strategies
  From the Iris book, chapter 14, covering Iris 14.0.0 on Go 1.27.
  Import path: github.com/kataras/iris/v14
  Canonical page: https://iris-go.com/docs/caching-strategies/
-->
# Chapter 14: Caching Strategies

Caching in an Iris application happens at three distinct layers: the client
(HTTP caching headers and `304 Not Modified` answers), the server (whole
responses stored and replayed without running your handlers), and the
application (your own data kept in memory so handlers skip expensive work).
Each layer saves a different cost, and they compose. This chapter covers the
`middleware/cache` package for client caching, the context's conditional
request helpers, the `middleware/cache/httpcache` package for server-side
response caching, plain-Go application caches wired through the builder's
service container, and the invalidation discipline that keeps all of them
honest.

## Background

A cache is a saved copy of a result, kept somewhere fast so the next request
for the same thing does not repeat the work that produced it. The work might
be a database query, a rendered template, a call to another service, or
simply transferring bytes over the network to a client that already has
them. Most applications answer the same questions over and over (the same
product list, the same profile page, the same country codes), and doing that
work once per unique answer instead of once per request is the entire idea.
When a request is answered from the copy, that is a *hit*; when the copy is
missing and the real work must run, a *miss*.

A cached copy can live in three kinds of places, and each saves a different
cost:

- **In the browser** (or any HTTP client). The client keeps the response it
  already downloaded and reuses it without contacting you. This saves the
  network itself: no request arrives at all.
- **In your server's memory.** Your Go process holds data or whole rendered
  responses in RAM. Requests still arrive, but they are answered without
  running the expensive code behind them. This copy is private to one
  process and vanishes when it restarts.
- **In a shared store.** A separate server (Redis or DragonflyDB in this
  chapter) holds the copies. Every instance of your application reads and
  writes the same store, so a copy made by one instance serves requests
  hitting another, and the copies survive your application's restarts.

Every copy comes with the same catch: the moment the underlying data
changes, the copy is wrong. A copy that no longer matches reality is called
*stale*. Two defenses exist. A *TTL* (time to live) is an expiry stamp: the
copy is trusted for, say, five minutes and then discarded regardless.
*Invalidation* is actively deleting or replacing the copy at the moment the
data changes. Invalidation is famously the hard part, because the code that
reads from the cache is usually far away from the code that changes the
data, and any path that changes data but forgets to invalidate produces
bugs that look like ghosts: correct code serving wrong answers.

Browser caching is steered by two response headers. `Cache-Control` is the
server's instruction about reuse: `max-age=3600` means "you may treat this
copy as fresh for an hour and not ask me again." An `ETag` is a short
fingerprint of the response body. When freshness expires, the browser does
not blindly redownload; it sends the fingerprint back and asks "has this
changed?" If not, the server answers `304 Not Modified` with an empty body
(a few bytes instead of the full payload) and the browser keeps using its
copy. That question-and-answer is called a *conditional request*, and it is
the mechanism behind most of this chapter's first half.

With the vocabulary in place (hit, miss, stale, TTL, invalidation,
`Cache-Control`, `ETag`, conditional request), the next section decides
which of the three places fits which problem, and the rest of the chapter
shows the Iris tool for each.

## Three Layers of Caching

Before reaching for a tool, decide which cost you are trying to avoid.

**Client caching** saves the network. The server attaches response headers
(`Cache-Control`, `Expires`, `ETag`, `Last-Modified`, `Vary`) that tell the
browser or an intermediate proxy how long a response stays fresh and how to
revalidate it. While the copy is fresh, the client does not contact you at
all; when it revalidates, you answer a conditional request with an empty
`304 Not Modified` instead of the full body. The cheapest request is the one
never made.

**Response caching** saves the server. The rendered response body and headers
are stored (in Iris: in DragonflyDB or Redis) under a key derived from the
request. An equivalent request within the TTL is answered from the store; your
handler, your services and your database never run. The request still travels
over the network, but it costs you almost nothing to answer.

**Application caching** saves specific expensive work. It is not an HTTP
concept: a service keeps data (a category list, exchange rates, a parsed
ruleset) in memory and refreshes it on its own schedule. Handlers run
normally; they just read from a fast source.

What separates the three is where the request stops, and how much work that
saves:

\[Diagram: Each caching layer stops the request earlier than the last: a fresh client cache never sends it, the cache middleware answers 304 before your handler runs, a response cache replays the whole response, and an application cache only saves the expensive call inside it.]

The rest of the chapter walks down this stack, then closes with invalidation,
which is where every caching design succeeds or fails.

## The Cache Middleware

The `middleware/cache` package sets HTTP caching response headers and answers
conditional `GET`/`HEAD`/`QUERY` requests with `304 Not Modified`. It does not store
responses; it teaches clients and proxies to. `cache.New(cache.Options{...})`
returns a regular handler that you register per route, per group or globally.

One header in the family needs a definition before it appears in code.
`Vary` tells caches which request headers change the answer. A response
compressed with gzip must never be replayed to a client that cannot
decompress it, so `Vary: Accept-Encoding` instructs caches to keep separate
copies per encoding the client accepts. Forgetting `Vary` is how one user's
variant of a response ends up served to another.

```go
package main

import (
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/cache"
)

func main() {
    app := iris.New()

    public := cache.New(cache.Options{
        ETag: true,
        CacheControl: cache.NewCacheControlBuilder().
            Public().
            MaxAge(time.Hour).      // browsers: fresh for one hour
            SMaxAge(2 * time.Hour). // shared caches: two hours
            MustRevalidate().
            Build(),
        LastModified: true,
        Expires:      24 * time.Hour,
        Pragma:       cache.NoPragmaCache,
        Vary:         cache.NewVaryBuilder().AcceptEncoding().Build(),
    })

    app.Get("/catalog", public, func(ctx iris.Context) {
        ctx.JSON(iris.Map{"items": []string{"a", "b", "c"}})
    })

    app.Listen(":8080")
}
```

`cache.Options` covers the full header family:

| Field               | Type            | Effect                                                                 |
| ------------------- | --------------- | ---------------------------------------------------------------------- |
| `ETag`              | `bool`          | Sets an `ETag` and answers matching `If-None-Match` with 304           |
| `CacheControl`      | `string`        | The `Cache-Control` value; build it with `CacheControlBuilder`         |
| `LastModified`      | `bool`          | Sets `Last-Modified` and answers matching `If-Modified-Since` with 304 |
| `Expires`           | `time.Duration` | Sets `Expires` to now plus the duration                                |
| `Pragma`            | `string`        | Sets `Pragma`; use `cache.NoPragmaCache` for HTTP/1.0 clients          |
| `Vary`              | `string`        | The `Vary` value; build it with `VaryBuilder`                          |
| `ProxyAuthenticate` | `string`        | Sets `Proxy-Authenticate`; build it with `ProxyAuthenticateBuilder`    |

`cache.DefaultOptions` is a ready public-caching preset: `ETag` and
`LastModified` on, `public, max-age=3600, s-maxage=7200, must-revalidate,
proxy-revalidate`, a 24-hour `Expires`, `Pragma: no-cache` and
`Vary: Accept-Encoding`. Register it as `cache.New(cache.DefaultOptions)`.

### The Builders

The three builders produce plain header strings, so you can also assemble
them once at startup and reuse the values:

```go
// "private, max-age=300"
private := cache.NewCacheControlBuilder().
    Private().
    MaxAge(5 * time.Minute).
    Build()

// "no-cache, no-store"
never := cache.NewCacheControlBuilder().
    NoCache().
    NoStore().
    Build()

// "Accept-Encoding, Accept-Language, Authorization"
vary := cache.NewVaryBuilder().
    AcceptEncoding().
    AcceptLanguage().
    Add("Authorization").
    Build()
```

`CacheControlBuilder` offers `Public()`, `Private()`, `NoCache()`,
`NoStore()`, `MaxAge(d)`, `SMaxAge(d)`, `MustRevalidate()` and
`ProxyRevalidate()`. The two max ages take a `time.Duration`, like every
other duration in `middleware/`, and the header carries whole seconds:
`MaxAge(time.Hour)` writes `max-age=3600`. A bare number still compiles
but means nanoseconds, so `MaxAge(3600)` panics at startup with a
message that tells you to pass a `time.Duration`. `VaryBuilder` offers
`Accept()`, `AcceptEncoding()`, `AcceptLanguage()`, `UserAgent()` and
the free-form `Add(name)`. `ProxyAuthenticateBuilder` offers
`Basic(realm)` and `Digest(realm, nonce, opaque)`. Each ends with
`Build()`.

Use `Private()` for per-user content that browsers may keep but shared proxies
must not, and `NoCache().NoStore()` for responses that must never be reused,
such as one-time tokens.

### The 304 Flow

On a `GET`, `HEAD` or `QUERY` request the middleware compares the request's
`If-None-Match` header against the `ETag` it manages, and `If-Modified-Since`
against its `Last-Modified` value. On a match it strips the content headers
and responds `304 Not Modified` with an empty body; your handler never runs.
Otherwise it sets the configured headers and calls `ctx.Next()`.

One honest caveat: the middleware derives its `ETag` and `Last-Modified`
values from the current time, not from your payload, so its revalidation
mainly collapses rapid repeat requests. Treat the middleware as a policy tool
(`Cache-Control`, `Expires`, `Vary`) and its 304 handling as a bonus. When
validators must reflect your data, for example a real modification time from
your database, write the conditional flow yourself with the context helpers
shown in [Conditional Requests by Hand](#conditional-requests-by-hand).

## Caching a Group of Endpoints

Most APIs contain a cluster of endpoints whose payload changes only on
deployment: country and currency lists, enum labels, plan descriptions,
public configuration. These "constants" endpoints are the perfect client
caching target, and the clean way to cache them is one middleware on one
group rather than a copy per route.

In the controller style of the recommended stack (see
[Chapter 4](https://iris-go.com/docs/routing-controllers/)), build the middleware inside
`Routing` and pass it to `r.Router` together with a closure that declares
the routes:

```go
package api

import (
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/cache"
)

// MetaAPI serves rarely-changing lookup data.
type MetaAPI struct{}

func NewMetaAPI() *MetaAPI { return &MetaAPI{} }

func (api *MetaAPI) Routing(r iris.Router) {
    cached := cache.New(cache.Options{
        ETag: true,
        CacheControl: cache.NewCacheControlBuilder().
            Public().
            MaxAge(24 * time.Hour). // refetched at most daily
            Build(),
        Vary: cache.NewVaryBuilder().AcceptEncoding().Build(),
    })

    r.Router("/", cached, func(r iris.Router) {
        r.Get("/countries", api.listCountries)
        r.Get("/currencies", api.listCurrencies)
        r.Get("/units", api.listUnits)
    })
}

func (api *MetaAPI) listCountries(ctx iris.Context) {
    ctx.JSON([]string{"DE", "GR", "JP", "US"})
}

func (api *MetaAPI) listCurrencies(ctx iris.Context) {
    ctx.JSON([]string{"EUR", "JPY", "USD"})
}

func (api *MetaAPI) listUnits(ctx iris.Context) {
    ctx.JSON([]string{"imperial", "metric"})
}
```

Mount it like any other controller, with the builder
(`API("/meta", api.NewMetaAPI)`) or directly
(`app.Router("/meta", api.NewMetaAPI)`).

Two details make the pattern work. `r.Router` accepts handlers among its
arguments: a `context.Handler` argument is registered with `Use` on the
group, so it applies to the matched routes registered after it. And the
path `"/"` does not create a child group; it returns the group itself, so
the middleware and the closure's routes attach to the controller's own
prefix. The result reads as a sentence: "everything in this block is
cached with this policy."

With a day-long `max-age`, each client downloads each list at most once a
day, and a deployment naturally refreshes the world within that window. If
that staleness bound is too loose for an endpoint, it does not belong in
the group.

## Conditional Requests by Hand

When the validator must come from your data, the context gives you the raw
pieces: `SetLastModified`, `CheckIfModifiedSince`, `WriteNotModified` and
the combined `WriteWithExpiration`.

```go
package main

import (
    "sync"
    "time"

    "github.com/kataras/iris/v14"
)

// Announcements is a tiny store that tracks its own modification time.
type Announcements struct {
    mu        sync.RWMutex
    items     []string
    updatedAt time.Time
}

func (a *Announcements) Snapshot() ([]string, time.Time) {
    a.mu.RLock()
    defer a.mu.RUnlock()
    return a.items, a.updatedAt
}

func (a *Announcements) Add(item string) {
    a.mu.Lock()
    defer a.mu.Unlock()
    a.items = append(a.items, item)
    a.updatedAt = time.Now()
}

func main() {
    app := iris.New()
    news := &Announcements{}
    news.Add("v14 released")

    app.Get("/announcements", func(ctx iris.Context) {
        items, updatedAt := news.Snapshot()

        modified, err := ctx.CheckIfModifiedSince(updatedAt)
        if !modified && err == nil {
            ctx.WriteNotModified() // 304, empty body
            return
        }

        ctx.SetLastModified(updatedAt)
        ctx.JSON(items)
    })

    app.Listen(":8080")
}
```

The `!modified && err == nil` idiom is deliberate. `CheckIfModifiedSince`
returns `false` with a non-nil error when the check could not run at all:
the method is not `GET`, `HEAD` or `QUERY`, the request carries no
`If-Modified-Since` header, the modification time is zero, or the header
failed to parse. Those errors wrap the context package's
`ErrPreconditionFailed`; they mean "send the full response", not "not
modified". Only `false` with a nil error licenses the 304.

`WriteNotModified` sends the 304 correctly: it removes `Content-Type`,
`Content-Length` and, when an `ETag` is present, `Last-Modified`, then sets
the status code. For byte payloads the whole dance collapses into one call:

```go
// 304 when the client is current, otherwise Last-Modified plus the body.
ctx.WriteWithExpiration(reportPDF, generatedAt)
```

On the request side, `ctx.MaxAge()` parses the request's `Cache-Control`
header and returns its max-age seconds as an `int64`, or `-1` when the
header is absent or unparsable. It is occasionally useful for honoring a
client's declared freshness tolerance in your own caching decisions.

## Server-Side Response Caching

The `middleware/cache/httpcache` package stores whole rendered responses in
DragonflyDB or Redis and replays them without executing your handler chain.
This is the layer that saves compute: the aggregation query, the template
render, the downstream API call all run at most once per TTL window. The
middleware ships with Iris; the store it talks to is an external
DragonflyDB or Redis server you operate.

If those names are new: Redis and DragonflyDB are key-value stores, small
standalone servers that keep data in RAM under string keys and answer
lookups in well under a millisecond. DragonflyDB speaks the Redis protocol,
which is why one client and one `Addr` option cover both. Being a separate
process is the feature: every instance of your application shares the one
store, so this is the "shared store" layer from the Background section.

```go
package main

import (
    "log"
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/cache/httpcache"
)

func main() {
    store, err := httpcache.New(httpcache.Options{
        Addr: "localhost:6379",
        TTL:  5 * time.Minute,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer store.Close()

    app := iris.New()

    app.Get("/reports/summary", store.Handler(), func(ctx iris.Context) {
        // This expensive aggregation runs at most once
        // per five-minute window.
        ctx.JSON(iris.Map{"total": 42, "generated": time.Now()})
    })

    app.Listen(":8080")
}
```

`store.Handler()` caches `GET`, `QUERY` and `POST` requests (the last two
because list endpoints with rich filters carry them in a JSON body, which
is why the body hash is part of the key) and stores
only successful `200` responses. On a hit it writes the stored headers and
body directly, plus a `Cache-Control: public, max-age=<remaining TTL>`
header so clients stack their own caching on top. On a miss it records the
response through a response recorder and stores it after your handlers
finish.

`httpcache.Options`:

| Field              | Effect                                                 |
| ------------------ | ------------------------------------------------------ |
| `Addr`, `Password` | DragonflyDB/Redis address and optional password        |
| `TTL`              | Entry lifetime; defaults to 10 minutes                 |
| `CommandTimeout`   | Store command timeout; defaults to 15 seconds          |
| `NoQuery`          | Exclude the query string from the cache key            |
| `NoBody`           | Exclude the request body from the cache key            |
| `Headers`          | Header names hashed into the key, e.g. `Authorization` |
| `Cookies`          | Cookie names hashed into the key, e.g. `session_id`    |
| `StartupCleanup`   | Flush the store on startup                             |
| `Disable`          | Turn the whole cache into a no-op                      |

Keys follow a readable structure:

```
httpcache:<route_tmpl>:<method>:<url_path>
    [:<query_hash>][:<body_hash>][:<headers_hash>][:<cookies_hash>]
```

The key is a single line, wrapped above for width. The route template
(for example `/articles/{id:uuid}`) anchors the key, so
all entries of one route share a prefix that invalidation can target. The
hashes cover whatever the options include.

Two of those options are safety-critical. If a cached endpoint returns
user-specific data, the user's identity must be part of the key: list the
`Authorization` header in `Headers` or the session cookie in `Cookies`.
Skip that and the first user's response is replayed to everyone. When a
response cannot tolerate that risk at all, do not response-cache it.

`Disable: true` returns a fully working no-op instance: `Handler` and
`Pair` return nil handlers (the framework skips nil middleware), and every
store method returns immediately. Development and test environments run
without a store while the wiring stays identical.

### Pairing Caching with Invalidation

A response cache is only trustworthy when mutations empty it. The package
wires this mechanically: `Pair(r)` returns a caching handler and an
invalidating handler bound to the same router group.

```go
package api

import (
    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/cache/httpcache"
)

type ArticlesAPI struct {
    store *httpcache.Cache
    // plus your service dependencies
}

func NewArticlesAPI(store *httpcache.Cache) *ArticlesAPI {
    return &ArticlesAPI{store: store}
}

func (api *ArticlesAPI) Routing(r iris.Router) {
    caching, invalidating := api.store.Pair(r)

    r.Get("/", caching, api.list)
    r.Get("/{id:uuid}", caching, api.read)
    r.Post("/", invalidating, api.create)
    r.Put("/{id:uuid}", invalidating, api.update)
    r.Delete("/{id:uuid}", invalidating, api.delete)
}

// list, read, create, update, delete are ordinary handlers.
```

At build time the invalidating handler collects the key prefixes of this
group's routes that carry the caching handler. At request time it runs
after your handler and, when a `POST`, `PUT`, `PATCH` or `DELETE` finishes
with `200`, `201` or `204`, deletes every entry under those prefixes with
batched scans. A successful create drops the stale list caches; a failed
one leaves them alone.

The building blocks are available separately when you need different
shapes: `InvalidateHandler(prefixes...)` invalidates explicit prefixes
(all `httpcache` entries when called with none), `InvalidateRoutes(r)` is
the invalidating half of `Pair`, and the store methods `Invalidate(ctx,
key)`, `InvalidatePrefix(ctx, prefix)`, `InvalidateAll(ctx)`,
`CountPrefix(ctx, prefix)` and `CountAll(ctx)` work from any code, such as
an admin endpoint or a background job. `RequestKey(r)` and
`ContextKey(ctx)` expose the key derivation itself.

With the builder stack, register the store as a service. It becomes
injectable into `NewArticlesAPI`, and because `*httpcache.Cache` has a
`Close() error` method, the container also registers it as a shutdown
closer (see [Chapter 8](https://iris-go.com/docs/dependency-injection/)):

```go
app := iris.NewBuilder().
    Prefix("/api").
    AllowOrigin("none").
    Compression(true).
    LogRequests(false).
    NoHealth().
    Errors(errors.NewOptions()).
    Services(store).
    RouterMiddlewares().
    Middlewares().
    API("/articles", api.NewArticlesAPI).
    Build()
```

Client caching and response caching are complements, not alternatives: the
response cache absorbs the requests that do arrive, while `Cache-Control`
headers keep many requests from arriving at all. Rarely-changing public
endpoints deserve both.

## Application-Level Caching

Below HTTP, caching is plain Go. Iris adds no special data-cache API here,
deliberately: a struct, a mutex and a TTL are easy to write, easy to test
and impossible to misdocument. What the framework contributes is
lifecycle. A cache registered through `Services` is injected wherever its
type is needed, warmed exactly once at startup via `iris.Initializer`, and
closed at shutdown when it has a `Close` method.

The example below uses a mutex, worth two sentences if you have not met
one. Your HTTP handlers run concurrently, so many of them may read the
cached list at the same moment a refresh is rewriting it; a mutex (mutual
exclusion lock) forces such access to take turns so nobody observes a
half-written value. The `sync.RWMutex` variant is the right fit for caches
because it lets any number of readers proceed together and only makes them
wait when a writer holds the lock.

A cache service that fronts a slow repository:

```go
package catalog

import (
    "context"
    "sync"
    "time"
)

// Repository is the slow source of truth (database, remote API).
type Repository interface {
    Categories(ctx context.Context) ([]string, error)
}

// Cache keeps the category list in memory and refreshes it when stale.
type Cache struct {
    repo Repository
    ttl  time.Duration

    mu        sync.RWMutex
    items     []string
    fetchedAt time.Time
}

func NewCache(repo Repository) *Cache {
    return &Cache{repo: repo, ttl: 5 * time.Minute}
}

// Init implements iris.Initializer. The builder calls it once inside
// Build(), after dependency injection and before the server accepts
// traffic, so the first request never sees a cold cache. A returned
// error aborts startup.
func (c *Cache) Init(ctx context.Context) error {
    return c.refresh(ctx)
}

// Categories returns the cached list, refreshing it when the TTL has
// passed. On refresh failure it serves the stale copy rather than fail.
func (c *Cache) Categories(ctx context.Context) ([]string, error) {
    c.mu.RLock()
    items := c.items
    fresh := time.Since(c.fetchedAt) < c.ttl
    c.mu.RUnlock()

    if fresh {
        return items, nil
    }

    if err := c.refresh(ctx); err != nil {
        if items != nil {
            return items, nil // stale beats broken
        }
        return nil, err
    }

    c.mu.RLock()
    items = c.items
    c.mu.RUnlock()
    return items, nil
}

func (c *Cache) refresh(ctx context.Context) error {
    items, err := c.repo.Categories(ctx)
    if err != nil {
        return err
    }

    c.mu.Lock()
    c.items = items
    c.fetchedAt = time.Now()
    c.mu.Unlock()
    return nil
}
```

Wire it in the builder's `Services` step; the container calls `NewCache`
with the registered repository and later hands the `*catalog.Cache` to any
controller constructor that asks for it:

```go
app := iris.NewBuilder().
    // ...
    Errors(errors.NewOptions()).
    Services(repo, catalog.NewCache).
    RouterMiddlewares().
    Middlewares().
    API("/catalog", api.NewCatalogAPI).
    Build()
```

The startup ordering is the point. `Init` runs after injection and before
serving, in registration order, which makes warmup failures a deploy-time
signal instead of a burst of slow first requests. `Deferrables` and
`Close` methods are the mirror image at shutdown
([Chapter 3](https://iris-go.com/docs/iris-newbuilder/) covers the full chain).

For bounded key-value caching without writing your own eviction, the
framework's internal generic cache is importable:
`github.com/kataras/iris/v14/core/pkg/cache`. `NewMemoryCache[T](maxCount)`
returns a `CacheIterator[T]` with `Get`, `Set`, `Delete`, `Walk` and
`First`; when the entry count exceeds `maxCount` (or a memory-derived
default when you pass zero), the oldest entries are evicted in batches.
Iris uses it for the geolocation middleware and the OAuth2 stores.

```go
import "github.com/kataras/iris/v14/core/pkg/cache"

profiles := cache.NewMemoryCache[Profile](10_000)

if err := profiles.Set(ctx, id, &profile); err != nil {
    return err
}

cached, found, err := profiles.Get(ctx, id) // (*Profile, bool, error)
```

[Chapter 20](https://iris-go.com/docs/performance-optimization/) revisits this package in the
context of measurement and tuning.

## Distributed Caching

**Third-party territory.** When several application instances must share
cached data, the cache moves to an external store: Redis, DragonflyDB or
Memcached, accessed through their own Go client libraries (for example
`github.com/redis/go-redis/v9`), which are third-party dependencies, not
Iris APIs. The pattern does not change: wrap the client in a small service
with the same shape as the in-memory `catalog.Cache` above, register it
through `Services`, and keep the client type out of your handlers. Note
that the built-in `httpcache` middleware is already distributed for its
own layer: every instance pointed at the same store shares one response
cache, and an invalidation from any instance is visible to all.

## Cache Invalidation

Invalidation is a design discipline more than an API, so this section is
advice rather than code.

**Give every entry a TTL.** Invalidation logic will have bugs; a TTL bounds
how long any bug can serve stale data. Pick the TTL from a business
question ("how stale may a price be?") and treat explicit invalidation as
an optimization on top, never as the only line of defense. The `httpcache`
store enforces this shape by design: entries always expire.

**Invalidate on the write path.** The code that changes data is the only
code that reliably knows data changed. The `Pair` pattern applies this
mechanically for response caches; follow the same rule in your own
services, where the method that writes also invalidates or updates the
corresponding cache entry. Invalidation sprinkled anywhere else, in
background sweepers or in readers, drifts out of sync with reality.

**Prefer coarse invalidation.** Dropping a whole route prefix after any
mutation is crude but easy to prove correct, and a cache exists to make
reads cheap, so rebuilding a handful of entries is usually noise. Precise
per-entry bookkeeping (which lists contain article 42?) is where stale-data
bugs live. Reach for it only when measurements show the coarse flush is
actually hurting.

**Client caches cannot be invalidated.** Once a browser holds a response
with `max-age=86400`, no server-side flush reaches it. So the `max-age`
you send is a promise: choose it as your true staleness tolerance. When
content must update promptly, send a short `max-age` (or `no-cache`) and
rely on cheap 304 revalidation. For static assets, invalidate by changing
the URL instead: fingerprinted file names with a long `max-age` give
instant updates and permanent caching at once.

**Do not cache what you cannot afford to leak.** Responses that vary by
identity need the identity in the cache key (`Headers`/`Cookies` in
`httpcache.Options`, `Vary` plus `private` on the client side) or no
response caching at all. An authorization bug through a cache is still an
authorization bug.

**Measure before layering.** A cache with a low hit rate is pure
complexity. Count hits and misses (the store's `CountPrefix` helps, as do
the metrics of [Chapter 17](https://iris-go.com/docs/monitoring-observability/)) before adding
another layer.

## Summary

- Caching has three layers, each saving a different cost: client caching
  saves the network, response caching saves the server, application
  caching saves specific expensive work. They compose.
- `middleware/cache` sets the header family (`Cache-Control`, `Expires`,
  `ETag`, `Last-Modified`, `Vary`, `Pragma`, `Proxy-Authenticate`) through
  `cache.New(cache.Options{...})` and the `CacheControlBuilder`,
  `VaryBuilder` and `ProxyAuthenticateBuilder` fluent builders, and
  answers conditional requests with `304 Not Modified`.
- Group rarely-changing "constants" endpoints under one cached router
  group: `r.Router("/", cached, func(r iris.Router) { ... })` keeps the
  policy in one place.
- For data-derived validators, use `ctx.CheckIfModifiedSince`,
  `ctx.SetLastModified`, `ctx.WriteNotModified` and
  `ctx.WriteWithExpiration`; remember that only `false` with a nil error
  means "not modified".
- `middleware/cache/httpcache` replays stored `200` responses for `GET`,
  `QUERY` and `POST` from DragonflyDB/Redis; `Pair` couples caching routes with
  automatic invalidation on successful mutations, and `Disable` makes the
  whole thing a no-op for development.
- Application caches are plain Go services: register them with
  `Services`, warm them once at startup through `iris.Initializer`, and
  let `Close` methods participate in graceful shutdown. The importable
  `core/pkg/cache` package provides a bounded generic memory cache.
- Invalidate on the write path, keep TTLs on everything, prefer coarse
  flushes, and treat client-side `max-age` as an unbreakable promise.

## Further Reading

- [MDN: HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching).
  The browser-side story in plain language: freshness, revalidation, and
  how shared and private caches differ.
- [RFC 9111: HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111): the
  authoritative specification every cache, browser or proxy, must follow.
- [MDN: Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control):
  every directive (`max-age`, `private`, `no-store`, `must-revalidate`)
  with examples of common recipes.
- [MDN: ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag):
  validators and conditional requests, the machinery behind
  `304 Not Modified`.
- [The Go sync package](https://pkg.go.dev/sync): `Mutex` and `RWMutex`,
  used by every hand-rolled in-memory cache.
- [go-redis](https://pkg.go.dev/github.com/redis/go-redis/v9): the Go
  client library for the Redis-protocol stores this chapter's distributed
  layers rely on.
