Informers and the Watch Mechanism in Kubernetes

Informers and the Watch Mechanism in Kubernetes

How Kubernetes components stay in sync with the cluster without hammering the API server: the watch stream, the resource version, the reflector, the local cache and the work queue, explained from first principles down to production tuning.

Imagine you run a busy restaurant kitchen. Every few seconds a cook walks to the front counter and asks, “Are there any new orders?” Most of the time the answer is no. The cook wastes steps, the counter staff get tired of answering, and when a big rush arrives the whole system jams. Now imagine a different kitchen where a small bell rings only when a new order lands, and a whiteboard in the kitchen always shows every open order. Cooks look at the whiteboard whenever they like and react only when the bell rings. That second kitchen is how Kubernetes components work, and the bell and whiteboard have names: the watch and the informer.

This post teaches both ideas from zero. You do not need to know Go, and you do not need to have written a controller. We will start with why constantly asking the API server is a bad idea, move on to the watch stream and resource versions, and then open up the client-side machinery that turns a raw stream into a reliable local cache. After that we cover failure recovery, scale, security, monitoring, patterns and mistakes. Every new term gets a plain definition and a real-life comparison, so take it one chapter at a time.

1The Problem: Why Polling Does Not Scale

Before we meet informers, we need to feel the pain they were invented to remove.

What polling means

Polling means asking the same question again and again on a timer. If a program wants to know which Pods exist, it can call the API server, receive the full list, compare it with the previous list, and repeat a few seconds later. This is simple to build, which is why it is the first idea almost every beginner reaches for. The trouble appears when the cluster grows or when many programs do the same thing at once.

Simple Analogy

Polling is like checking your mailbox every five minutes all day. Most trips find nothing, you waste energy, and the postal worker still has to keep the box accessible. A doorbell that rings only when a parcel arrives is far cheaper for everyone.

The three costs of polling

Cost 1

Wasted bandwidth

Each poll returns the entire list even when nothing changed. A cluster with tens of thousands of Pods turns every poll into megabytes of repeated data.

Cost 2

Server load

Listing a large resource forces the server to fetch, encode and send many objects. Multiply that by dozens of controllers and the API server spends its life answering the same question.

Cost 3

Slow reactions

A change is only noticed at the next poll. Polling more often reduces delay but increases the first two costs, so you can never win.

Cost 4

Missed in-between events

If an object changes twice between two polls, the program only sees the final state. For some jobs that is fine, for others it hides important history.

!
Common Misconception

Many beginners think “just poll less often” is the fix. It only trades CPU for staleness. The real fix is to stop asking and start listening, which is exactly what the watch mechanism provides.

What Kubernetes needed instead

Kubernetes is built around many small programs called controllers. Each one keeps looking at the cluster and nudges reality toward what you asked for. A typical cluster runs dozens of built-in controllers, the scheduler, a kubelet on every node, a kube-proxy on every node, plus any operators you install. All of them need an up-to-date picture of objects. The design goal was therefore clear: let every component hold a fresh local copy of the objects it cares about, and only send small updates when something changes. That goal produced two cooperating pieces. The server side offers a streaming watch. The client side offers an informer that consumes the stream and maintains the local copy.

2Control Plane Refresher: Where Watches Fit

A short tour of the parts that talk to each other, so the rest of the post has solid ground.

The cast of characters

The API server is the front door of Kubernetes. Every read and every write goes through it, whether from kubectl, the scheduler, a kubelet or your own operator. Behind it sits etcd, a reliable key-value database that stores the entire cluster state. Around it live controllers, small loops that compare desired state, which is what you declared, with actual state, which is what currently exists, and then act to close the gap.

Component

API server

Validates, stores and serves objects. It is the only component that talks to etcd directly.

Component

etcd

Durable storage with a global, ever-increasing revision number that tags every change.

Component

Controllers

Loops that watch objects and reconcile differences, such as keeping the right number of Pod replicas alive.

Component

Scheduler and kubelet

The scheduler assigns Pods to nodes. The kubelet on each node starts the containers. Both react to changes rather than poll.

Simple Analogy

Think of a city hall. The API server is the single service desk. etcd is the locked archive room with every record. Controllers are city workers who read notices from the desk, then go fix potholes or lights. Nobody is allowed into the archive except through the desk, so the desk becomes the one place where changes can be announced fairly to everyone.

Why the hub-and-spoke shape matters

Because all communication flows through the API server, components never talk to each other directly. The scheduler does not tell the kubelet to start a Pod. It writes a decision into the Pod object, and the kubelet, which is watching Pods assigned to its node, sees that change and acts. This indirection makes the system loosely coupled and easy to extend, but it also means the API server becomes the busiest crossroads in the cluster. Anything that reduces unnecessary traffic through that crossroads is a big win, and that is the whole job of the watch and informer design.

flowchart LR
  U["kubectl and users"] --> A["API Server"]
  A  E[("etcd")]
  A -- "watch stream" --> S["Scheduler"]
  A -- "watch stream" --> C["Controller Manager"]
  A -- "watch stream" --> K["Kubelet on each node"]
  A -- "watch stream" --> O["Your Operator"]
        
FIG 1 — Every component reads and writes through the API server and receives changes as watch streams

Figure 1 shows the shape. Notice that arrows from the API server to each component are streams, not repeated questions. Each component opens one long-lived connection per kind of object it cares about and then simply receives news as it happens. The sections ahead explain what travels on that stream and how each client turns it into something dependable.

3The List and Watch Pair

Almost every Kubernetes client follows the same two-step dance: list once, then watch forever.

Step one: list

A list call returns every object of a given kind, optionally filtered by namespace, label or field. It is a snapshot, a photo of the world at one instant. The response also carries a bookmark of that instant, which we will meet shortly as the resourceVersion.

Step two: watch

A watch call tells the API server, “Keep this connection open and tell me about every change after this point.” The server responds with a continuous stream of small messages, one per change. Under the hood this is an ordinary HTTP request whose response never ends, with each event delivered as a separate chunk. Modern clusters carry this over HTTP/2, which lets many streams share a single connection to the server.

Simple Analogy

Listing is like taking a photograph of a whiteboard. Watching is like then standing next to the whiteboard and writing down every eraser stroke and every new marker line as it happens. Together, the photo plus the stroke log let you reproduce the whiteboard at any later moment.

Why both steps are needed

A watch alone only reports changes. If you start watching with no starting point you would not know what already exists. A list alone only gives a snapshot that goes stale at once. Put them together and you get a complete, always-fresh picture: the list provides the base, and the watch applies every difference on top of it. This pattern is so central that the Kubernetes client library wraps it in a dedicated component, which later chapters call the reflector.

1

Client lists all objects of one kind

The API server returns the objects plus the resource version that describes the moment of the snapshot.

2

Client stores the snapshot locally

The objects fill an in-memory store that the program can read at any time without touching the network.

3

Client opens a watch from that resource version

The server promises to send every change that happened after that exact point, with nothing skipped and nothing repeated.

4

Events keep the local copy fresh

Each added, modified or deleted event is applied to the store, so the local copy tracks the cluster within moments.

i
Good to Know

You can watch from the command line too. Running a get with the watch flag keeps the terminal open and prints a new line whenever a matching object changes. That is the same stream the controllers use, just displayed for humans.

4Resource Version: The Bookmark of Time

The single most important idea for understanding why watches are reliable.

What a resource version is

Every object in Kubernetes carries a field called resourceVersion. It is a string that the API server fills in, and for objects stored in etcd it is derived from etcd’s global revision counter. Each time anything in etcd changes, the counter moves forward. The object that changed receives the new number as its resource version. So a resource version is essentially a timestamp measured in changes rather than in seconds.

Simple Analogy

Picture a bank ledger where every transaction gets the next page number. If you say, “I have read up to page 4,120,” anyone can hand you page 4,121 onward and you will not miss or repeat a single entry. A resource version is that page number for the whole cluster.

The golden rule: treat it as opaque

Clients must treat the value as an opaque token. You may pass it back to the server, but you must never do arithmetic on it or assume it is numeric on every storage backend. The one operation that is officially supported is equality, which is used for concurrency control. When you update an object and send back the resource version you originally read, the server accepts the write only if the object has not changed since. This is called optimistic concurrency, and it prevents two controllers from silently overwriting each other.

Two jobs, one field

Job 1

Conflict detection

Writers send the version they last saw. If it is stale, the server rejects the write with a conflict, and the writer re-reads and retries.

Job 2

Watch positioning

Watchers send the version they last saw to tell the server exactly where to resume the stream.

List versus object versions

A list response has its own resource version in its metadata. It represents the point in time of the whole snapshot, and it is the value a client should hand to its follow-up watch. Individual objects inside the list have their own versions that reflect when each one last changed, and those can be much older. Mixing these up is a classic source of confusing bugs, so remember: the list’s version is the starting line for the watch.

!
Common Mistake

Do not compare resource versions of different kinds of objects and conclude one is “newer” by order. The values come from one shared counter, but the only safe comparisons are for equality and for passing them back to the server as a starting point.

Read semantics in one table

When a client lists, it can ask for different freshness guarantees by choosing how to set the resource version on the request. The table below summarises the common choices in plain language.

Request choiceWhat the server doesWhen it fits
Version left unsetReads the most recent data, normally confirmed through etcd, so it is strongly consistentWhen you absolutely need the latest truth and can afford the cost
Version set to zeroServes from the API server’s own cache, which may be slightly behind but is very cheapInformers starting up, where a tiny lag is harmless
Specific version, not older thanServes data at least as fresh as that version, from cache if possibleResuming without going backwards in time
Specific version, exactServes data exactly at that point if it is still availableRare, consistent paging and debugging

Informers deliberately choose the cheap, cache-served option on their first list and then rely on the watch to catch up to the present. That design is one of the main reasons large clusters survive controller restarts without the API server falling over.

5Watch Events and the Life of a Stream

What exactly travels down the connection, and how does the connection end?

The event types

Each message on the stream contains an event type and the full object that the event is about. There are only a handful of types, so they are easy to learn.

Type

ADDED

A new object appeared, or, at the start of a watch with no version, an object that already existed is being introduced.

Type

MODIFIED

An existing object changed. The message carries the entire new version of the object, not just the changed fields.

Type

DELETED

The object was removed. The message carries the object’s final known state so that watchers can clean up.

Type

BOOKMARK

A lightweight progress marker that says “you are now caught up to this version.” It carries no real change and exists to help clients resume cheaply.

Type

ERROR

Something went wrong, for example the requested starting point is too old. The client must react, usually by listing again.

i
Whole Object, Not a Patch

Because each event carries the complete object, a client never has to reconstruct state from partial diffs. The cost is slightly larger messages, the benefit is that missing nothing but the very last event for an object still leaves you with the right final state.

Ordering guarantees

For a single kind of object, events arrive in the order the changes happened in etcd. That means a client will never see an object’s deletion before its creation. Across different kinds there is no promise that a Pod event and a Service event appear in any particular relative order, because they come over different streams. Programs that depend on cross-object ordering must be designed to cope with seeing things in either order, which is one reason controllers are written to look at the current state rather than at event order.

How a stream ends

No stream lives forever. The API server deliberately closes watches after a randomised period, which is typically in the range of half an hour to an hour by default, so that connections are spread out over time and load balancers and restarts do not cause a thundering herd. Streams also end because of network blips, API server restarts, or rolling upgrades. A well-built client treats a closed stream as normal, waits briefly with a growing delay, and reconnects from the last version it saw.

The too-old problem

The server cannot remember history forever. etcd compacts old revisions regularly so it does not grow without bound, and the API server’s own cache holds only a limited window of recent events. If a client tries to resume from a version that has been forgotten, the server answers with a status that effectively means “that moment is gone.” The HTTP status is 410 Gone, and the reason shows up as an Expired or “too old resource version” message. The only safe response is to start over with a fresh list. We cover this recovery path in detail in Chapter 10.

sequenceDiagram
  participant C as Client
  participant S as API Server
  C->>S: List pods
  S-->>C: Snapshot plus version 100
  C->>S: Watch from version 100
  S-->>C: ADDED pod-a version 101
  S-->>C: MODIFIED pod-a version 105
  S-->>C: BOOKMARK version 110
  S-->>C: DELETED pod-a version 112
  Note over C,S: Stream closes after timeout
  C->>S: Watch from version 112
  S-->>C: Events continue
        
FIG 2 — A watch stream: snapshot, events, bookmark, and a clean resume after the stream closes

Read figure 2 from top to bottom. The client lists, notes version 100, and opens the watch from there. Changes arrive with growing version numbers. A bookmark confirms progress without any real change. When the stream closes, the client does not start from scratch, it simply resumes from version 112, the last number it saw. That tiny habit of remembering the last version is what makes reconnects cheap.

6Inside the API Server: The Watch Cache

If every watcher talked to etcd directly, etcd would melt. The watch cache is the shield.

The fan-out problem

Imagine one thousand nodes, each running a kubelet that watches Pods. If each of those one thousand watchers created its own watch against etcd, the database would need to track one thousand separate streams for the same data and would repeat the same work for each. etcd is built for correctness and durability, not for serving thousands of identical readers cheaply.

One watch in, many watches out

The API server solves this with a component often called the cacher or watch cache. For each kind of resource, the API server opens a single watch against etcd and keeps an in-memory copy of all objects of that kind. It also keeps a rolling buffer of the most recent events. When a client opens a watch on the API server, the cacher registers it and feeds it from that shared buffer. One upstream stream becomes many downstream streams.

Simple Analogy

Think of a radio station. The station listens to one news wire, then broadcasts to thousands of receivers at once. Nobody calls the news agency individually. The watch cache is the radio station for cluster changes.

flowchart TD
  E[("etcd")] -- "one watch per resource kind" --> W["Watch cache in API server"]
  W --> B["Recent event buffer"]
  W --> M["In-memory object copy"]
  B --> C1["Watcher: kubelet 1"]
  B --> C2["Watcher: scheduler"]
  B --> C3["Watcher: operator"]
  M --> L["Cheap list requests"]
        
FIG 3 — The watch cache fans out one etcd watch into many client watches and serves lists from memory

What the cache buys you

Cheaper lists

Because the cache already holds every object in memory, list requests that allow slightly stale data are answered without touching etcd at all. This is why informers prefer that style of list when starting up.

Cheaper watches

Adding one more watcher costs a bit of memory and some network, but no additional etcd work. The cluster can support thousands of watchers with a single upstream stream per resource.

Shared encoding work

The server can serialise an event once and send the same bytes to many watchers instead of encoding it again for each one, which saves CPU during busy periods.

Resumable history window

The event buffer lets a client that reconnects within the window resume exactly where it stopped. Outside the window, the server tells the client to list again, which is the too-old case from the previous chapter.

Slow watchers and back-pressure

Not every watcher keeps up. If a client reads its stream too slowly, the server has to buffer events for it, and it cannot buffer forever. When a watcher falls too far behind, the server terminates that watch rather than harm everyone else, and the client must reconnect and recover. This protects the shared cache but makes it important for clients to process events quickly. The right pattern, shown later, is to do almost no work inside the event receiver and hand heavy work to a separate queue.

i
Progress Notifications

The server can also send those bookmark events on a schedule when the client asks for them. They let a quiet watcher keep an up-to-date resume point even when its own resource has not changed for a long time, so a later reconnect does not hit the too-old wall.

7Client Side: The Building Blocks of an Informer

An informer is not one thing. It is a small assembly line made of four cooperating parts.

The assembly line

A raw watch stream is not enough for a real program. Streams drop, events can be missed during restarts, and handlers need a tidy, consistent view of the world. The Kubernetes client library therefore builds an informer from several components, each with one clear job.

Part 1

Reflector

Talks to the API server. It performs the list, opens the watch, handles reconnects, and pushes what it learns into the queue.

Part 2

DeltaFIFO queue

A first-in-first-out queue that holds “deltas,” small records describing what happened to each object, such as added, updated, deleted or replaced.

Part 3

Indexer and store

The local in-memory database. It keeps the latest copy of every object and lets you find them quickly by name, namespace or any custom key.

Part 4

Controller loop and handlers

Pops items from the queue, updates the store, and then notifies the registered event handlers that something changed.

Simple Analogy

Picture a newspaper office. The reflector is the reporter who gathers news from the outside world. The DeltaFIFO is the inbox where stories wait their turn. The indexer is the archive of the latest edition of every story, neatly filed. The handlers are the editors who decide what to do when a new story lands.

The reflector in detail

The reflector owns the list-and-watch conversation. On start it lists the objects, takes the resource version from the response, and replaces the contents of the queue with that snapshot. Then it opens a watch from that version and translates every incoming event into a queue entry. If the watch closes, it waits using a growing back-off delay and reopens it from the last version it handled. If the server says the version is too old, the reflector discards its resume point and begins again with a fresh list. The reflector deliberately knows nothing about what your program wants to do with objects. Its only duty is to keep the pipeline truthful.

The DeltaFIFO queue in detail

The queue stores work per object key, not per event. If an object changes three times before the consumer takes it, the queue keeps one entry for that key holding an ordered list of the three deltas. This grouping stops one noisy object from flooding the system with separate items and keeps the history per object in the right order. The queue also supports a special “replace” operation, used after a re-list, which lets it work out which objects vanished while the program was disconnected and generate the missing deletions.

The indexer and store in detail

The store is a thread-safe map from an object’s key, usually “namespace/name”, to the latest copy of the object. The indexer adds secondary lookup tables on top. By default there is an index by namespace, and you can add your own, for example “find all Pods on node X” or “find all objects owned by this parent.” Lookups are answered entirely from memory, so reading from the cache is typically microseconds rather than milliseconds, and costs the API server nothing.

flowchart LR
  API["API Server"] -- "list then watch" --> R["Reflector"]
  R -- "deltas" --> Q["DeltaFIFO"]
  Q -- "pop" --> L["Controller loop"]
  L -- "update" --> I[("Indexer and store")]
  L -- "notify" --> H["Event handlers"]
  H -- "enqueue key" --> W["Work queue"]
  I -- "read latest" --> K["Your reconcile logic"]
  W -- "next key" --> K
        
FIG 4 — Inside an informer: reflector, queue, store, handlers, and the downstream work queue
!
Do Not Mutate the Cache

Objects returned from the local store are shared with everyone else using that informer. Changing one in place silently corrupts what every other reader sees. Always make a copy before modifying anything you got from the cache.

8Shared Informers and the Factory

Why one program should never open two watches for the same kind of object.

The duplication trap

A large program such as the controller manager runs dozens of controllers, and many of them care about Pods. If each controller built its own informer, the program would open dozens of identical watches, store dozens of identical copies of every Pod, and burn memory and network for no benefit. The API server would also see a pile of redundant streams from one process.

The shared informer idea

A shared informer keeps one reflector, one queue and one cache per kind of object, and lets any number of consumers attach event handlers to it. Everyone reads from the same cache and receives the same notifications. A shared informer factory is the manager that hands out these informers: when two parts of the program ask for a Pod informer, the factory returns the very same instance both times and starts it only once.

Simple Analogy

A shared informer is a single office newspaper subscription circulated to the whole team. Without sharing, every employee would buy a personal copy of the same paper, filling the office with duplicates and costing far more.

Advantages of sharing

  • One watch per kind per process, so far less load on the API server
  • One copy of each object in memory instead of many
  • Consistent view: every consumer sees the same state at the same time
  • Simple lifecycle: start the factory once and wait for sync once
  • Late handlers get replayed existing objects so they start with a complete picture

Trade-offs to remember

  • Objects are shared, so any in-place mutation harms everyone
  • A slow handler can delay delivery for other handlers on the same informer
  • The factory must be started after all informers are requested
  • Memory grows with the number of objects, even those you rarely read

Waiting for the cache to sync

When an informer starts, its cache is empty until the first list finishes. If your logic reads the cache too early, it will conclude that nothing exists and may take harmful actions, such as creating duplicates of things that are really there. Informers therefore expose a “has synced” signal that turns true once the initial snapshot has been fully loaded and delivered. The standard practice is to start the informers, wait for every one of them to report synced, and only then start the workers. Skipping this wait is one of the most common causes of controller bugs that appear only after a restart.

Scoping what you watch

Informers can be limited by namespace, label selector or field selector, which the server applies before sending data. A kubelet, for instance, only needs Pods assigned to its own node, so it asks the server for exactly that slice. Filtering at the server reduces network traffic, memory use and CPU on both sides. The rule of thumb is simple: never cache more than you need.

i
Specialised Flavours

There are variations for special needs. A metadata-only informer caches just names, labels and owners, which is ideal when you do not need the full spec. A dynamic informer works with kinds that are not known at compile time, which suits generic tools. Both reuse the same list-and-watch core.

9Event Handlers and the Work Queue Pattern

The informer tells you something changed. The work queue decides how and when you react.

The three handler callbacks

When you register with an informer, you provide up to three small functions: one called when an object is added, one when it is updated, and one when it is deleted. The update callback receives both the old and the new copy of the object, which lets you compare them and skip changes you do not care about. The delete callback sometimes receives a special wrapper called a tombstone instead of the object itself. This happens when the client missed the real delete event, for example during a disconnect, and only discovered the deletion later during a re-list. A careful handler checks for that wrapper before using the object.

Why handlers must be tiny

Handlers run on the informer’s own delivery path. If a handler does slow work, such as calling a remote service, every later notification waits behind it, and the server may eventually drop the lagging watch. The proven pattern is therefore to do almost nothing inside the handler except work out a short key for the object, typically “namespace/name”, and place that key into a separate work queue. Real processing happens in worker routines that pull keys from that queue.

Simple Analogy

A hospital reception desk writes the patient’s name on a waiting list and sends them on. It does not perform surgery at the desk. Doctors, the workers, call patients from the list when they are ready. The desk stays fast, and no patient is lost even on a busy day.

What the work queue adds

Feature

De-duplication

If the same key is added ten times before a worker takes it, it is processed once. Fast-changing objects do not create ten jobs.

Feature

Per-key serialisation

The same key is never handled by two workers at once, which removes a whole class of race conditions.

Feature

Rate-limited retries

When processing fails, the key goes back with an increasing delay, so a broken object does not spin the CPU or flood the API server.

Feature

Graceful shutdown

The queue can be told to stop accepting work and let workers finish, which makes clean rollouts of controllers possible.

Keys, not objects

A subtle but powerful design choice is that the work queue carries keys rather than whole objects. When a worker picks up a key, it looks up the freshest copy from the local cache at that moment. If five updates happened while the key waited, the worker simply sees the latest state and does one round of work. Carrying copies instead would risk acting on stale versions. This trick turns a bursty event stream into calm, bounded, always-current work.

1

Event arrives at the handler

The handler computes the key for the changed object and immediately returns.

2

Key waits in the work queue

Duplicates merge, and retries are delayed according to the rate limiter.

3

A worker takes the key

It reads the latest object from the cache and compares desired against actual state.

4

The worker reconciles and reports

On success the key is forgotten, on failure it is requeued with back-off, so the system keeps trying until things match.

10End-to-End: Data Flow and Lifecycle

Let us follow one real change from a user’s command to a controller’s reaction.

A day in the life of a change

Suppose you scale a Deployment from three replicas to five. The command reaches the API server, which validates it and writes the new desired state to etcd. etcd assigns a new revision. The API server’s watch on Deployments receives that change and stores it in the cache and event buffer. Then the fan-out begins: every watcher of Deployments gets the event, including the Deployment controller inside the controller manager.

sequenceDiagram
  participant U as User
  participant A as API Server
  participant E as etcd
  participant R as Reflector
  participant Q as DeltaFIFO
  participant I as Indexer
  participant H as Handler
  participant W as Work Queue
  participant X as Worker
  U->>A: Scale deployment to 5
  A->>E: Store new desired state
  E-->>A: Watch event with new revision
  A-->>R: MODIFIED deployment event
  R->>Q: Add delta
  Q->>I: Pop and update cache
  Q->>H: Notify update
  H->>W: Add key
  W->>X: Hand over key
  X->>I: Read latest deployment
  X->>A: Create missing pods
        
FIG 5 — One scaling command travelling through the watch pipeline to a worker action

Step-by-step reading of the flow

In the controller process, the reflector receives the event and appends a delta to the queue. The informer’s loop pops it, updates the local store, and then calls the update handler. The handler adds the Deployment’s key to the work queue. A worker takes the key, reads the freshest Deployment from the cache, sees that five replicas are wanted and three exist, and asks the API server to create two more child objects. Those creations generate their own watch events, which travel through the same kind of pipeline in other controllers, for example the one that manages ReplicaSets and then the scheduler that picks nodes.

i
Chain Reaction

Notice that no component ever calls another one directly. Each change in the database triggers the next component through its watch. This is how a single command ripples through dozens of independent programs without a central conductor.

Typical timing

In a healthy cluster, the time from a database write to a handler call is usually a few milliseconds to tens of milliseconds. The dominant delays in real scenarios come later, such as image pulls or container start-up, not from the watch pipeline. That speed is exactly what polling could never deliver without punishing the server.

1
upstream etcd watch per resource kind in the API server
1
shared informer per kind in each client process
0
API calls for ordinary cache reads

11Failure Recovery: Resync, Relist and 410 Gone

Networks fail, servers restart, history expires. Informers are built to heal themselves.

Three different healing actions

People often blur three separate ideas, so let us separate them carefully. They have different triggers and different costs.

ActionTriggerTalks to API server?Purpose
ReconnectStream closed, network blip or server timeoutYes, reopens the watch from the last versionContinue seamlessly without losing anything
RelistResume point too old, or the stream could not be resumedYes, full list plus new watchRebuild a trustworthy snapshot and detect missed deletions
ResyncA timer configured on the informerNo, it replays the local cache to handlersGive handlers another chance to reprocess objects

Reconnect: the cheap path

Most interruptions end in a simple reconnect. The reflector remembers the last resource version it saw, waits briefly, and opens a new watch from there. The server replays any events it still has after that point from the watch cache, and the client catches up with no gaps and no duplicates. Because of bookmarks, even a watcher on a very quiet resource keeps a fresh resume point.

Relist: the safe path

If the reconnect gets a 410 Gone, the client must assume it missed an unknown number of changes. It lists again, and the queue’s replace operation compares the new snapshot against what the cache believed. Objects that disappeared in the meantime produce synthetic delete events, and those carry the tombstone wrapper mentioned earlier because the real delete was never seen. Objects that are new or changed produce add or update events. When it finishes, the cache is correct again, and the handlers have been told about every difference.

flowchart TD
  S["Watch stream closes"] --> R{"Can resume from last version?"}
  R -- "Yes" --> RC["Reconnect with back-off"]
  RC --> OK["Continue receiving events"]
  R -- "No, 410 Gone" --> RL["Relist all objects"]
  RL --> RP["Replace queue contents and compute differences"]
  RP --> SD["Emit synthetic deletes with tombstones"]
  SD --> OK
        
FIG 6 — The recovery decision: cheap reconnect when possible, full relist when history is gone

Resync: the second chance

A resync does not involve the network at all. At a configured interval, the informer replays every object in its local cache to the update handlers as if each had just changed. Why would you want that? Because a handler or worker might have failed on an object and never retried, or a bug might have left something half-done. A periodic replay gives your logic another look at everything. Many modern controllers set the interval to zero, meaning “never,” because the work queue’s retries already cover failures, and unnecessary resyncs waste CPU in big clusters. If you do use it, remember that the update handler will be called with identical old and new objects, so a handler that treats every update as a real change will do redundant work.

!
Resync Is Not Relist

A resync never fetches new data from the server. It only replays what is already in memory. If your cache is wrong because of a bug, a resync will faithfully replay the wrong data. Only a relist repairs the cache itself.

Back-off and jitter

When many clients lose their connection at the same instant, such as during an API server rolling upgrade, they must not all retry at the same moment. Informers wait with exponentially growing delays and add randomness, so reconnects spread over time. Without that discipline, the very recovery attempts would overload the server that just came back.

12Level-Triggered Versus Edge-Triggered Thinking

The mental model that decides whether your controller is robust or fragile.

Two ways to react to the world

An edge-triggered system reacts to the moment of change: “a Pod was just deleted, so do X.” A level-triggered system reacts to the current state: “there should be five Pods, I count three, so make two more.” The difference is huge when events can be lost, repeated or reordered, and in distributed systems they often are.

Simple Analogy

An edge-triggered thermostat only reacts at the instant someone presses a button. If the press is missed, the room stays cold forever. A level-triggered thermostat keeps checking the temperature and heats whenever it is below the target, so it fixes itself even after a missed press.

Kubernetes chooses level

Informers deliver edge-style events, but good controllers use them only as hints to wake up. The actual decision comes from comparing desired and actual state read from the cache. If an event is lost, duplicated or merged with another, the next reconcile still computes the right answer. This is why the work queue de-duplicates keys and why workers re-read the cache instead of trusting event contents. The event says “look at this object again,” not “here is exactly what happened.”

Level-based reconcile

  • Self-healing after lost or merged events
  • Safe to run repeatedly with the same result
  • Easy to reason about: one question, “does reality match the spec?”
  • Works naturally with resync and restarts

Event-based reaction

  • Breaks when an event is missed during a disconnect
  • Often assumes ordering that the system does not guarantee
  • Hard to restart safely, since past events cannot be replayed on demand

Idempotency is the partner of level triggering

Because the same key may be processed many times, each reconcile must be idempotent, meaning that running it twice produces the same outcome as running it once. If the Deployment already has five Pods, a second pass does nothing. Designing for repeatability is the price of resilience, and it is a price worth paying every time.

“Events are only a nudge to look again. The truth always lives in the current state.”

13Who Uses Informers: Real-World Examples

The watch mechanism is not an exotic feature. It is the heartbeat of the entire platform.

The kube-scheduler

The scheduler keeps informers on Pods, Nodes, storage objects and more. It notices new unscheduled Pods the moment they appear, reads node capacity from its local cache while scoring candidates, and writes a binding back to the API server. Because scoring reads only from memory, it can evaluate many nodes in a fraction of a second without calling the server for each one.

The kubelet on each node

Each kubelet watches only the Pods assigned to its own node by using a field selector. When the scheduler writes a node name into a Pod, that kubelet receives the event and starts the containers. It also watches the Secrets and ConfigMaps its Pods use, so rotated values reach running workloads without polling.

kube-proxy

kube-proxy watches Services and the endpoint information behind them. When a backend Pod becomes ready or disappears, the change flows in, and the node’s forwarding rules are updated so traffic reaches only healthy backends.

The controller manager

Dozens of built-in controllers, for Deployments, ReplicaSets, Jobs, namespaces, service accounts and more, share one informer factory. This is the textbook example of shared informers saving memory and API traffic.

Operators and custom controllers

Third-party operators for databases, certificates and message brokers watch their own custom resources, plus the built-in objects they create. The popular frameworks for writing them wrap the same informer machinery, so even teams who never mention the word still depend on it.

Ingress and service mesh controllers

Components that configure proxies and load balancers watch Ingress, Gateway and Service objects and translate changes into new proxy configuration within moments of you applying a manifest.

i
A Telling Detail

The speed at which a new Pod gets a node, or an Ingress rule starts routing traffic, is largely a story of watch streams doing their quiet job. When these feel slow, the cause is nearly always elsewhere or in a lagging watcher, rarely in the design itself.

14Performance and Scalability

Informers are efficient by design, but at scale the details start to matter.

Where the cost actually goes

The big costs of an informer are memory and the initial list. Memory grows with the number and size of cached objects. The initial list, repeated whenever a relist is needed, can be a heavy moment for both the client and the server, because every object must be encoded, sent and decoded. In a cluster with hundreds of thousands of objects, careless clients can cause noticeable pressure on the control plane just by restarting together.

Practical tuning levers

Lever 1

Narrow the watch

Use namespace, label and field selectors so the server sends only what you need. This is the highest-impact change in most cases.

Lever 2

Cache less per object

Trim big fields you never read before storing, or use a metadata-only informer when names and labels are enough.

Lever 3

Use compact encoding

Binary encoding is smaller and faster to decode than text formats. Built-in kinds support it, custom resources generally use text.

Lever 4

Avoid needless resyncs

Disable the periodic replay unless you truly need it, since it multiplies handler work by the number of cached objects.

Lever 5

Stagger restarts

When you roll thousands of clients, let them start gradually so the initial lists do not land together.

Lever 6

Add custom indexes

If your logic often searches by a field, build an index for it rather than scanning the whole cache each time.

Streaming the initial list

Newer Kubernetes versions improve the start-up path with a mechanism that lets a client ask the server to stream the initial state through the same watch connection, followed by a marker to say the initial burst is complete. This replaces one huge list response with a steady stream and lowers peak memory on the API server. Availability depends on your cluster version and feature settings, so check the release notes for the version you run before relying on it.

Protecting the server

The API server uses a fairness system that groups requests into priority levels and queues them, so that one misbehaving client cannot starve others. Long-running watch requests are handled differently from short requests, but the initial lists and the writes your controllers make still pass through that fairness system. A client that relists in a tight loop can still harm itself by being throttled, which shows up as slow reconciles. Good back-off behaviour keeps you on the right side of the limits.

SymptomLikely causeWhere to look
Controller memory keeps climbingCaching too many or too large objectsSelectors, metadata-only informers, trimming
Spikes of API load after a rolloutMany clients relisting togetherStaggered restarts, streaming initial list
Reactions arrive lateSlow handlers or a deep work queueHandler duration, worker count, queue depth
Frequent watch restartsSlow consumer or unstable network pathServer watch terminations, proxy timeouts

15Security Considerations

A watch is a read permission with a long reach. Treat it with the care it deserves.

Watching needs explicit permission

Role-based access control treats list and watch as separate verbs, and informers need both, plus get in some cases. Granting a controller the right to list and watch Secrets across the cluster means it can read every Secret in every namespace. That is a very powerful permission, and the stream will keep delivering updated values as they change. Always scope controllers to the narrowest kinds, namespaces and verbs they truly need.

!
Hidden Power of List

Permission to list a kind is permission to read the full contents of every object of that kind, not just their names. The same is true for watch. Many teams forget this and grant broad read access believing it only covers harmless metadata.

Identity and transport

Each client authenticates to the API server, usually with a service account token for in-cluster programs or a client certificate for node components. The connection is encrypted in transit. Because watch connections are long-lived, short-lived tokens are refreshed in the background, and a client that mishandles refresh may find its watch ended with an authentication error, which should trigger a clean reconnect rather than a crash loop.

Cache hygiene

The local cache holds a full copy of sensitive objects in process memory. Take care not to log whole objects, avoid writing the cache to disk, and keep Secret informers out of programs that do not need them. If a process is compromised, everything in its caches is exposed, so the principle of least data applies just like least privilege.

Auditing and visibility

Audit logs record watch requests, including who opened them and for which resource. Reviewing unusual watch patterns, such as a new service account watching all Secrets, is a practical way to catch both mistakes and malicious use. Watch events themselves are not logged one by one, but the changes that cause them are recorded as the original writes.

Security checklist for informer-based programs

Grant only list and watch on the kinds you need. Restrict by namespace when you can. Prefer metadata-only informers when full content is unnecessary. Never log whole cached objects. Review audit logs for unexpected watchers after each deployment.

16Monitoring, Logging and Metrics

You cannot fix what you cannot see. Informers expose plenty of signals if you know where to look.

Watching the work queue

Controllers built with the standard libraries publish metrics about their work queues. The most useful ones tell a simple story. Queue depth shows how many keys are waiting. The add counter shows how fast new work arrives. Queue latency shows how long keys wait before a worker picks them up. Work duration shows how long each reconcile takes. Retry counters show how often reconciles fail and come back. A healthy controller has a shallow queue, short waits and few retries.

Client signal

Queue depth

A depth that grows without coming back down means workers cannot keep up or are stuck on failing keys.

Client signal

Queue latency

The time between a key being added and being picked up. Rising latency is an early warning of overload.

Client signal

Work duration

How long each reconcile takes. A sudden increase often points to a slow dependency or a larger cache.

Client signal

Retries

A steady stream of retries for the same keys usually means a permanent error being treated as temporary.

Watching the server side

The API server exports metrics about registered watchers per resource kind, the number of watch events sent, the size of those events, and the number of watches it terminated. A jump in registered watchers can indicate a buggy client opening streams without closing old ones. A rise in terminated watches can point to slow consumers. etcd’s own metrics for database size, compaction and request latency complete the picture, because slow storage eventually slows the entire event pipeline.

Logging with purpose

Log the key being reconciled, the outcome and the duration, rather than the whole object. Log reconnects and relists at a level where you can count them, since a sudden rise in relists is one of the clearest signs of trouble. Include the resource kind in each message so that logs from different informers can be told apart in a busy process.

A simple health routine

1

Check the sync signal at start-up

Confirm every informer reports synced before workers run, and fail the readiness check until then.

2

Track queue depth and latency

Alert when waiting time or depth stays high for several minutes, not just for a moment.

3

Count relists and restarts

A gentle baseline is normal. A sharp increase deserves investigation of network paths and server health.

4

Watch memory trend

A slow, steady climb suggests the cache is growing with the cluster, so revisit selectors and trimming.

17Design Patterns and Best Practices

Habits that experienced controller authors apply almost automatically.

Patterns that work

Informer plus work queue plus reconcile

The core pattern. Handlers enqueue keys, workers read the latest state from the cache, and a single reconcile function brings reality closer to the spec. Almost every healthy controller is a variation of this shape.

Owner references and child lookups

When a parent creates children, mark the ownership. Then a child’s change can be mapped back to its parent’s key, so the parent is reconciled when something under it breaks. Informers on both parent and child kinds make this natural.

Status as a separate concern

Write results to the status section of an object, not into its spec. This keeps your own writes from looking like user edits and helps avoid endless loops where a controller keeps reacting to itself.

Leader election for single-writer work

Run several replicas for availability, but let only one actively reconcile. All replicas may keep warm caches, so a failover resumes instantly without paying the cost of a cold start.

Predicates and filters before enqueue

Compare the old and new object in the update handler and ignore changes that do not matter, such as status-only updates or identical resyncs. This cuts the work queue’s volume drastically.

Best practices at a glance

Do

  • Share one informer factory per process
  • Wait for cache sync before starting workers
  • Keep handlers tiny and push keys to a work queue
  • Copy objects before changing them
  • Make reconcile idempotent and level-based
  • Scope watches with selectors and namespaces

Do not

  • Poll the API server in a loop when an informer exists
  • Trust event contents instead of the cache’s current state
  • Do heavy or slow work inside handlers
  • Assume resource versions can be compared by size
  • Ignore tombstones on delete events
  • Grant cluster-wide Secret watch rights casually
i
Read From the Cache, Write To the Server

A handy rule: all reads go to the local cache, all writes go to the API server. A freshly written change may take a moment to show up in the cache, so avoid reading back your own write immediately and expecting to see it. Let the next event bring it in.

18Common Mistakes and Anti-Patterns

Real production incidents, distilled into a few memorable lessons.

ANTI-PATTERN-01 Avoid
Problem

A controller starts its workers immediately after launching the informers, without waiting for the initial sync to finish.

Why It’s Harmful

For a short window the cache is empty or partial. The workers conclude that expected objects do not exist and create duplicates or delete things that are still valid. The bug appears only after restarts, which makes it hard to reproduce.

Correct Approach

Start all informers, wait until every one reports that it has synced, and only then start the workers. Make readiness depend on this signal.

ANTI-PATTERN-02 Avoid
Problem

Code modifies an object obtained from the local cache directly, for example editing a label before sending an update.

Why It’s Harmful

The cached object is shared by every reader of that informer. The edit silently changes what other controllers see, even though the server never accepted that change. Strange, non-reproducible behaviour follows.

Correct Approach

Always take a deep copy of any cached object before changing it, and send only the copy to the server.

ANTI-PATTERN-03 Avoid
Problem

Slow work, such as calling an external service or waiting on a lock, is performed directly inside an event handler.

Why It’s Harmful

The handler blocks delivery for everything behind it. The informer falls behind, the server may cut off the lagging watch, and the client then relists, adding even more load while still behind.

Correct Approach

Compute a key, enqueue it, and return at once. Perform the slow work inside workers that read from the queue.

ANTI-PATTERN-04 Avoid
Problem

The program builds a separate informer for every controller or component instead of sharing through a factory.

Why It’s Harmful

Memory use multiplies, the API server sees many redundant watch streams, and different parts of the program may briefly disagree about the state of the same object.

Correct Approach

Create one shared informer factory per process and obtain every informer from it, so each kind is watched and cached once.

ANTI-PATTERN-05 Avoid
Problem

The reconcile logic is written around specific events, for example “when a Pod is deleted, create a replacement,” and ignores the current state.

Why It’s Harmful

If the delete event is missed, merged or delivered while the controller was down, the replacement is never made and the system stays wrong indefinitely.

Correct Approach

Make reconcile level-based. On every wake-up, compare the desired count against what exists in the cache and act on the difference.

ANTI-PATTERN-06 Avoid
Problem

A custom client implements its own raw watch loop and, on any error, immediately re-lists everything in a tight loop with no delay.

Why It’s Harmful

During an outage, thousands of such clients hammer the server the moment it recovers, prolonging the outage. It also skips the careful bookkeeping that the standard reflector already provides.

Correct Approach

Use the standard informer machinery wherever possible. If a custom loop is unavoidable, resume from the last version, use growing delays with randomness, and relist only when told the version is too old.

19Advantages, Disadvantages and Trade-offs

No design is free. Here is an honest look at what informers give and what they ask in return.

Advantages

  • Near real-time reactions without constant asking
  • Dramatically lower load on the API server and etcd
  • Reads from local memory are fast and cost nothing remote
  • Built-in recovery from disconnects and expired history
  • A uniform pattern that every controller author can learn once
  • Loose coupling between components through shared state changes

Disadvantages / Trade-offs

  • Memory cost grows with the number and size of cached objects
  • The cache is eventually consistent, so reads may lag slightly
  • Start-up requires a full list, which can be heavy at scale
  • Learning curve: several moving parts and subtle rules
  • Shared objects make accidental mutation a real danger
  • Debugging is harder because behaviour depends on timing

Informer cache versus direct read

Sometimes you need the strictest possible freshness, for example before a destructive action. A direct read from the API server can be made strongly consistent, while the informer cache is allowed to be a little behind. Most controllers accept the small lag because they are level-triggered and will correct themselves on the next event, but critical one-off checks may justify the extra cost of asking the server directly. Choose deliberately, not by habit.

ApproachFreshnessCostBest used for
Informer cacheVery close to current, tiny lag possibleMemory only, no API callsRoutine reconcile loops and lookups
Direct read from serverCan be strongly consistentOne API call each timeRare, high-stakes decisions
Polling loopDelayed by intervalRepeated full readsAlmost never the right choice in Kubernetes

20Frequently Asked Questions

Questions that beginners and experienced engineers alike keep asking.

Q1Is an informer the same thing as a watch?

No. A watch is the server feature that streams changes over one connection. An informer is a client-side assembly that uses a list and a watch, adds a queue and a local cache, handles reconnects and relists, and notifies your handlers. The watch is the ingredient, the informer is the finished dish.

Q2Why does the first list use the cache instead of etcd?

Because reading from the API server’s in-memory cache is far cheaper than a fresh read through etcd, and the watch that follows will immediately fill in any tiny gap. The result is correct and much lighter on the control plane, especially when many clients start together.

Q3What happens if my controller is down for a while?

When it comes back, the informer lists the current state, compares it with its empty or old cache, and delivers add events for everything that exists. Because the logic is level-based, the controller then reconciles reality with the spec regardless of what it missed. No event history is required.

Q4Why am I seeing 410 Gone errors in my logs?

It means the client tried to resume from a resource version that the server no longer remembers, usually because the connection was down for too long or the stream was very quiet without bookmarks. The client recovers by relisting. Occasional occurrences are normal, constant ones suggest an unstable network path, a very slow consumer, or heavy write churn compacting history quickly.

Q5Do I need a resync period?

Often not. The work queue’s retry behaviour already handles failures. A resync replays cached objects to handlers, which helps if your logic might leave something half-done without retrying, but it also adds load. Use it only when you can name the problem it solves.

Q6Can two controllers share one informer?

Yes, and they should. Through a shared informer factory they attach separate handlers to the same cache and stream. This saves memory and API traffic. Just remember that they share the same object copies, so neither may modify cached objects in place.

Q7Why does the work queue store keys and not objects?

Keys let the worker read the freshest version from the cache at processing time and let the queue merge duplicates. Storing objects would risk acting on out-of-date copies and would multiply memory use during bursts.

Q8Are watch events guaranteed to arrive exactly once?

Do not rely on that. Within a continuous stream they arrive in order, but across reconnects, relists and resyncs your handlers may see repeats, merged changes or synthetic deletes. Design for at-least-once and idempotent handling rather than exactly-once.

Q9How do bookmarks help?

They are small progress markers that update the client’s resume point even when nothing relevant changed. A quiet watcher can then reconnect later from a recent version instead of an ancient one, which avoids unnecessary relists.

Q10Is it safe to read my own write straight from the cache?

Not immediately. The change must travel from the server through the watch back into your cache, which takes a short moment. Design your logic so that the next event brings the new state, instead of expecting it instantly.

Q11What is a tombstone in delete handling?

It is a wrapper that holds the last known state of an object whose delete event was missed and later discovered during a relist. Handlers should check for it before treating the value as a normal object, otherwise they may fail on an unexpected type.

Q12Does this work for custom resources too?

Yes. Custom resources are served by the same API server with the same list and watch behaviour, so the same informer machinery applies, whether through generated typed clients or generic dynamic ones.

21Summary and Key Takeaways

Kubernetes avoids the trap of constant polling by splitting the job in two. The API server offers a list that gives a snapshot and a watch that streams every later change, positioned by an opaque resource version. Inside the server, a watch cache turns one expensive upstream stream into many cheap downstream ones. On the client, an informer combines a reflector, a DeltaFIFO queue, an indexed local store and event handlers into a self-healing pipeline, and a work queue turns noisy events into calm, de-duplicated, retryable work. Level-based, idempotent reconcile logic ties everything together so that lost or repeated events never leave the cluster permanently wrong.

If you remember one picture, remember the kitchen from the start: a bell that rings only when something changes, and a whiteboard that always shows the current truth. The watch is the bell, the informer is the whiteboard, and your controller is the cook who looks at the board and does whatever is needed to make reality match the order.

Key Takeaways

  • List then watch — a snapshot plus a change stream gives a complete, always-fresh view without repeated full reads.
  • Resource version is a bookmark — treat it as an opaque token, use it to resume watches and to detect write conflicts, and never compare it by size.
  • Watch cache protects etcd — the API server fans one etcd watch out to many clients and serves cheap lists from memory.
  • Informer equals reflector, queue, store and handlers — each part has one job, and together they handle reconnects, relists and consistent local reads.
  • Share informers, keep handlers tiny — one factory per process, enqueue keys, and let workers read the latest object from the cache.
  • Reconcile by level, not by edge — events are only nudges, and idempotent logic comparing desired and actual state heals from any missed event.
  • Operate it deliberately — scope watches narrowly, grant least-privilege list and watch rights, wait for cache sync, and monitor queue depth, latency, retries and relists.