How the Kubernetes controller-runtime Cache Actually Works (and Why You Should Care)
Controller-runtime's cache is what lets your operator read cluster state without hammering the API server — but it does a lot more than cache. Informers, ListWatch, structured stores, and why understanding the internals saves you from stale-read bugs and memory OOMs.
If you have ever written a Kubernetes operator using controller-runtime, you have used the cache. Every r.Get() and r.List() call in your reconciler goes through it. Most developers treat it as a black box — it works until it doesn’t, and when it breaks, the symptoms are confusing: stale reads that return deleted objects, memory usage that climbs without explanation, or informers that silently drop events.
The cache is not just a lookup table. It is a carefully layered Go interface that wraps the full client-go informer machinery — Reflectors, DeltaFIFOs, ThreadSafeStores, and indexers — and exposes it as a client.Reader. Understanding those layers is what separates someone who can debug an informer from someone who just restarts the pod and hopes.
The Interface
At the top level, the cache.Cache interface from sigs.k8s.io/controller-runtime/pkg/cache (v0.24.1, as of May 2026) is deceptively simple:
type Cache interface {
client.Reader
Informers
}
A client.Reader provides Get and List methods that look like direct API calls but read from local state. An Informers provides GetInformer, Start, WaitForCacheSync, and the FieldIndexer interface for adding custom indices. That simplicity hides the machinery underneath.
The Options struct is where most of the tuning lives: SyncPeriod (defaults to 10 hours with 10% jitter), DefaultNamespaces for scoping watches, DefaultTransform for stripping managed fields before they hit the cache (added in v0.18.0), and ByObject-level overrides for per-GVK configuration (added in v0.15.0). The ReaderFailOnMissingInformer option (defaults to false) changes behaviour: when false, the cache auto-starts an informer for any resource you request; when true, it returns an ErrResourceNotCached if no informer exists.
The Informer Layer
When you call r.Get(ctx, key, &myResource{}), the cache does not go to the API server. It looks up the registered informer for that resource’s GVK and reads from the informer’s internal store.
Each informer is backed by a SharedIndexInformer from client-go, which is itself built from three components:
Reflector. This is the component that talks to the API server. It runs a ListWatch — an initial LIST call to fetch the full state of a resource, followed by a long-lived WATCH connection for incremental updates. The Reflector feeds everything into a DeltaFIFO.
DeltaFIFO. A first-in-first-out queue that records deltas (Added, Updated, Deleted, Sync) for each object. The informer’s controller loop pops items from this queue and distributes them to the registered event handlers.
ThreadSafeStore with indexers. The underlying object store. Objects are stored by namespace/name key and can be indexed by arbitrary fields. When you add an index with cache.IndexField(ctx, obj, field, extractFn), it registers against this store. A List call that specifies field selectors or label selectors uses these indices to filter, avoiding iteration over every object.
The key detail is that Get and List against the cache never leave the process. Every object your reconciler reads came from a local copy that was populated by a watch event — possibly seconds, minutes, or hours ago.
What the SyncPeriod Actually Does
The SyncPeriod option is widely misunderstood. It defaults to 10 hours, and many developers assume it controls how often the cache refreshes from the API server. It does not.
As the controller-runtime docs state: “SyncPeriod does not sync between the local cache and the server.” The period controls a local resync: it periodically generates an artificial Update event for every object in the cache, with the same object in both ObjectOld and ObjectNew. Its purpose is to guard against bugs — either in your controller that might not requeue an object that needs requeuing, or in controller-runtime itself.
A side effect is that predicates like GenerationChangedPredicate filter out these synthetic events because the old and new objects are identical. If you are relying on SyncPeriod to pick up changes you missed in the watch stream, you are relying on something that was never designed for that purpose.
Memory
Every object the watch delivers gets stored in the ThreadSafeStore in its entirety. If you are watching Pod objects across all namespaces on a 500-node cluster with 50,000 pods, you have 50,000 Pod objects in memory — each with its full spec, status, metadata, and managed fields.
The TransformStripManagedFields function (added in v0.18.0) was introduced specifically to address this. It strips the managedFields metadata from every object before it enters the cache. Managed fields can be a significant portion of an object’s serialised size, especially on frequently updated resources. Setting DefaultTransform: cache.TransformStripManagedFields on your cache options can produce a measurable reduction in memory pressure without losing any information most controllers actually use.
Per-object transforms via ByObject.Transform let you go further — strip unnecessary fields on a per-resource basis. The UnsafeDisableDeepCopy flag exists for performance-critical paths where you are certain you will never mutate what the cache returns, but the name is a warning: if you get it wrong, you corrupt the cache for every other reader.
Stale Reads and Why They Happen
Consider the most common footgun: you create a resource, wait for it to exist, then r.Get it — and get a not found. This is not a bug. The cache’s informer has not yet received the watch event for the created object, so the local store does not have it.
The WaitForCacheSync method on the cache blocks until all informers report that their initial LIST has completed and the DeltaFIFO has been fully processed. But it only guarantees initial sync. Subsequent writes may not have propagated yet.
The only reliable way to handle this is to accept that the cache is eventually consistent. If you need to read your own writes, the controller-runtime approach is to return a requeue from your reconciler. The next reconcile loop runs after a backoff, by which point the cache should have caught up. For the rare cases where this is not acceptable, you can bypass the cache entirely and use a non-cached client directly against the API server — but that defeats the purpose of using controller-runtime in the first place.
Why This Matters Beyond the Cache
The design of the controller-runtime cache reflects a broader philosophy about Kubernetes control planes: the local cache reduces API server load and avoids the thundering-herd problem of every controller re-listing every resource on every reconciliation. But it shifts the operational complexity from the API server to the controller process.
Some platforms address this at the system level. Talos Linux, for example, uses an immutable, API-driven OS design that eliminates the attack surface of SSH and package management. Its control plane components run as static pods with minimal overhead. That philosophy also shows up in control-plane management platforms like Omni, which handle cluster lifecycle across fleets through a centralised control plane.
Omni uses Infrastructure Providers to handle bare-metal provisioning, VM lifecycle, and decommissioning through a single abstraction layer. Its Workload Proxy feature routes traffic to cluster-internal services through an IDP-gated, encrypted tunnel — no port-forwards, no separate VPNs, no public ingress to configure. The point is not that this kind of tooling replaces the controller-runtime cache; it is that lean, minimal infrastructure reduces the surface area where caching problems arise. If you have fewer control-plane components that need to cache state from the API server, you have fewer problems with stale reads and memory growth in the first place.
The cache is a good abstraction, but it is not transparent. Understanding what happens between r.Get and the object in memory — the Reflector, the DeltaFIFO, the store with its indices, the SyncPeriod that does not do what it sounds like — is what keeps you from debugging for three hours and blaming the wrong layer.