Chapter 14

Caching Strategies

Three layers: client caching with headers and 304s, server-side response caching, and in-application caching. Where each one stops a request.

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:

Three caching layers and where each one stops a request 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. browser or proxy Cache-Control max-age still fresh? client caching hit no request is sent at all saves the network entirely miss, or stale middleware/cache: If-None-Match or If-Modified-Since matches? hit 304 Not Modified, empty body your handler never runs, so this saves the body bytes miss, or stale httpcache: GET or POST, and the key is in Redis or DragonflyDB? hit the stored response is replayed headers and body, plus Cache-Control: public, max-age=<remaining TTL>. Handlers, services and queries all stay unexecuted. miss, or stale your handler chain the response is recorded, then stored when the status is 200 application cache: TTL not elapsed? hit the in-memory copy is returned the repository is not touched: this saves one expensive call miss, or stale database or remote API The cache middleware derives its ETag from time.Now() and its Last-Modified from now minus 24h, and its 304 path returns before Cache-Control, Expires and Vary are set, so it carries no policy headers. Use ctx.CheckIfModifiedSince when the validator must come from your own data. For the response cache, list Authorization in Headers or the session cookie in Cookies, or one user's response is replayed to everyone.
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:

FieldTypeEffect
ETagboolSets an ETag and answers matching If-None-Match with 304
CacheControlstringThe Cache-Control value; build it with CacheControlBuilder
LastModifiedboolSets Last-Modified and answers matching If-Modified-Since with 304
Expirestime.DurationSets Expires to now plus the duration
PragmastringSets Pragma; use cache.NoPragmaCache for HTTP/1.0 clients
VarystringThe Vary value; build it with VaryBuilder
ProxyAuthenticatestringSets 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.

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), 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:

FieldEffect
Addr, PasswordDragonflyDB/Redis address and optional password
TTLEntry lifetime; defaults to 10 minutes
CommandTimeoutStore command timeout; defaults to 15 seconds
NoQueryExclude the query string from the cache key
NoBodyExclude the request body from the cache key
HeadersHeader names hashed into the key, e.g. Authorization
CookiesCookie names hashed into the key, e.g. session_id
StartupCleanupFlush the store on startup
DisableTurn 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):

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 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 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) 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. The browser-side story in plain language: freshness, revalidation, and how shared and private caches differ.
  • RFC 9111: HTTP Caching: the authoritative specification every cache, browser or proxy, must follow.
  • MDN: Cache-Control: every directive (max-age, private, no-store, must-revalidate) with examples of common recipes.
  • MDN: ETag: validators and conditional requests, the machinery behind 304 Not Modified.
  • The Go sync package: Mutex and RWMutex, used by every hand-rolled in-memory cache.
  • go-redis: the Go client library for the Redis-protocol stores this chapter’s distributed layers rely on.

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.