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:
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.
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:
// "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:
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.
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:
// 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.
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.
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):
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:
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:
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.
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/cachesets the header family (Cache-Control,Expires,ETag,Last-Modified,Vary,Pragma,Proxy-Authenticate) throughcache.New(cache.Options{...})and theCacheControlBuilder,VaryBuilderandProxyAuthenticateBuilderfluent builders, and answers conditional requests with304 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.WriteNotModifiedandctx.WriteWithExpiration; remember that onlyfalsewith a nil error means “not modified”. middleware/cache/httpcachereplays stored200responses forGET,QUERYandPOSTfrom DragonflyDB/Redis;Paircouples caching routes with automatic invalidation on successful mutations, andDisablemakes the whole thing a no-op for development.- Application caches are plain Go services: register them with
Services, warm them once at startup throughiris.Initializer, and letClosemethods participate in graceful shutdown. The importablecore/pkg/cachepackage provides a bounded generic memory cache. - Invalidate on the write path, keep TTLs on everything, prefer coarse
flushes, and treat client-side
max-ageas 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:
MutexandRWMutex, 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.