Informer resync: the API call that never happens
Lowering resyncPeriod to get fresher data is advice built on a misreading, and the client-go source settles it in one line
The controller was acting on stale data, so somebody lowered resyncPeriod from ten minutes to thirty seconds. It didn't help. Then to five seconds, which didn't help either, and made the process burn CPU on a schedule.
Resync doesn't fetch anything. From the client-go docs on AddEventHandlerWithResyncPeriod:
The resync operation consists of delivering to the handler an update notification for every object in the informer's local cache; it does not add any interactions with the authoritative storage.
Every word of that is load-bearing, and the last clause is the one that gets skipped.
What an informer is made of
Four pieces, and they're easier to reason about named:
A reflector does one LIST against the API server, then opens a WATCH from the resourceVersion that LIST returned. It's the only component that talks to the API server at all.
A DeltaFIFO queues what the reflector produces, as typed deltas: Added, Updated, Deleted, Sync.
An Indexer is the local cache - a thread-safe store with secondary indexes, so your controller can ask for "all pods on node X" without a query leaving the process.
Event handlers are your code, called as deltas drain out of the queue.
Everything a controller reads comes from the Indexer. client.Get() through a cached client, a lister, the informer's GetStore() - all of it is memory, populated by that one watch stream. That's the whole point of the pattern: one watch per resource type per process, shared across every controller in the binary, instead of N controllers polling.
What a resync actually does
The current client-go handles a full resync in five lines, and they answer the question completely:
case SyncAll:
objs := clientState.List()
for _, obj := range objs {
handler.OnUpdate(obj, obj)
}clientState is the local store. OnUpdate(obj, obj) passes the same object as both the old and the new value.
So a resync walks your cache and calls your update handler once per object, with old and new identical. Nothing is fetched. Nothing is compared. If your handler starts with a check like "did the spec change since the last version" it will find that it didn't, every time, for every object.
Two consequences fall out of this immediately.
A handler that diffs old against new does nothing on resync. If you wrote your controller to skip work when nothing changed - which is a good habit - resync is a no-op for you, and turning it down will never make anything fresher.
A handler that enqueues unconditionally does full work on resync. Cache of fifty thousand pods, resync every thirty seconds, and you've signed up for fifty thousand reconcile enqueues every thirty seconds. The API server sees none of it. Your CPU graph sees all of it.
Then what is it for
Resync exists for drift between your cache and something the cache can't observe.
A controller that creates cloud load balancers can't see someone deleting one in the console. No Kubernetes object changed, so no watch event fires, and the controller has no reason to look. A periodic resync re-runs reconciliation for everything and gives it that reason.
That's the honest use case, and it's why the sensible values are minutes, not seconds. Ten to thirty minutes covers the drift case. Anything shorter is usually somebody trying to fix a freshness problem that resync has never addressed.
If you have no external state to drift against, zero is a legitimate setting. Plenty of controllers run with resync disabled and are correct, because the watch already delivers every change to every object they care about.
When the data really does go stale
Two failure modes, and neither is fixed by resync.
The watch broke and the reflector is re-listing. Watches die - connection resets, apiserver rollouts, a resourceVersion too old for the watch cache to serve. The reflector handles this by re-listing and starting a new watch, and during that window your cache is whatever it was. Watch HasSynced, which the source is explicit about:
HasSynced returns true if the shared informer's store has been informed by at least one full LIST of the authoritative state of the informer's object collection. This is unrelated to "resync".
You read your own write. You update an object through the API and immediately read it back from the cache. The write went to etcd; the cache updates when the watch event arrives, which is milliseconds later but not zero. Controllers that assume read-after-write against a cached client have a race, and it's the most common informer bug I've seen. If you need the object you just wrote, either read through an uncached client or structure the reconcile to be re-entrant and let the next event carry the new state.
The expensive part is the LIST
Resync is free for the API server. The initial LIST is not.
Historically the apiserver assembled the entire collection in memory before sending a byte of it, so a controller restarting against a hundred thousand pods produced a large allocation spike on the control plane, and a cluster full of controllers restarting together produced several at once.
That's what streaming lists fix. Under KEP-3157 the initial state arrives as a watch that streams items individually, giving the apiserver constant memory overhead instead of one proportional to the collection. Server-side it's the WatchList gate; client-side it's WatchListClient, which reached beta and is on by default. Both are beta rather than GA, so it's worth knowing which of your controllers actually use it before you take credit for the memory graph.
The apiserver side of this story - how watch requests get served without touching etcd at all - is the watch cache, and it's a separate mechanism from anything in this piece.
The limits worth knowing
Resync periods are per handler, but not freely. A shared informer has one resync check period, and a handler asking for a shorter interval than that gets the informer's, not its own. Requesting two seconds on an informer configured for ten minutes gets you ten minutes.
Shared informers are also shared in a way that bites: the informer factory dedupes by resource type, so two controllers asking for pod informers get the same one, along with each other's resync behaviour and each other's cache memory. That's usually what you want and it's occasionally why a memory number doesn't match anyone's mental model of who allocated it.
And the cache holds full objects. An informer on pods in a large cluster is holding every pod spec and status in your process. Transform functions on the informer let you strip fields you'll never read before they hit the store, which is the cheapest memory win available to most controllers and one almost nobody applies.


