Watch Cache and API Server Caching in Kubernetes
How the Kubernetes API server answers thousands of clients without overwhelming its database, what the watch cache really is, and how to stay on the right side of it.
Imagine a library with one precious master record book kept in a locked vault. Every visitor who wants to know whether a book is on the shelf must ask the clerk, and the clerk must walk to the vault, open the heavy book, find the page and walk back. With ten visitors this is fine. With ten thousand visitors asking every few seconds, the clerk collapses and the vault door wears out. So a clever library keeps a current copy of the record on a desk near the entrance, and keeps a short list of the most recent changes pinned beside it. Visitors read the desk copy. The vault is visited only once, by one person, to keep the desk copy up to date.
Kubernetes has exactly this problem and exactly this solution. The “vault” is a database called etcd, the “clerk” is the API server, and the “desk copy” is called the watch cache. In this guide you will learn how the watch cache works, how clients ask for data in a way that uses it, what other caches sit in the API server, how clients keep their own caches, and what can go wrong. Every new word is explained the first time it appears, so you can start from zero.
1The Problem: Everyone Wants to Know What Changed
Caching in Kubernetes is not a performance trick added at the end. The whole design depends on it from the beginning.
Kubernetes in sixty seconds
A cluster is a group of computers working together, and each computer is a node. The brain of the cluster is the control plane. At its centre sits the API server (the program named kube-apiserver), which is the front door for every request. Behind it sits etcd, a small, strongly consistent database that stores the official record of every object: every Pod, Service, Secret and configuration.
Kubernetes works by comparing “what the record says should exist” with “what actually exists,” and fixing any difference. The programs that do this fixing are called controllers. The scheduler, which picks nodes for Pods, is one. The kubelet, the agent on each node that starts containers, is another. Each of these programs needs to know about changes the moment they happen, so it does not just ask once. It keeps listening.
The crowd at the front door
Count how many programs are listening in a medium cluster of 1,000 nodes. Every kubelet watches the Pods assigned to its node. Every kube-proxy watches Services and their endpoints. The scheduler watches Pods and nodes. Dozens of built-in controllers watch their own kinds of objects. Add monitoring agents, ingress controllers, service meshes, operators and CI tools. Easily tens of thousands of long-lived connections are open at once, and each one wants to hear about every relevant change within a second or two.
If each of those listeners caused its own query against etcd, the database would drown. etcd is built for correctness, not for being asked the same question ten thousand times a second. It keeps strict order on every write and agrees with its peers before answering. That is exactly what you want for a source of truth, and exactly what you do not want for a crowd.
Think of a stock exchange. The matching engine that records every trade is slow and careful by design. Thousands of traders do not call the engine directly; they watch a price board that is fed by a single line from the engine. The board is the watch cache. It shows nearly instant, nearly current information to everyone at once, while the engine does its careful job in peace.
The solution in one sentence
The API server opens one watch on etcd for each kind of object, keeps a full in-memory copy of those objects plus a short history of recent changes, and serves all clients from that memory. Ten thousand listeners cost etcd the same as one.
Direct reads from etcd
Early on, the API server went to etcd for most reads. This was simple, but it limited how big a cluster could grow.
The watch cache arrives
In the early releases the API server gained an in-memory cache fed by one watch per resource type. It was a key step toward clusters of thousands of nodes.
Client libraries learn to use it
The informer pattern in client libraries made “list once, then watch” the standard way to write controllers.
Leaner traffic
Features such as node leases, endpoint slices and bookmarks removed unnecessary updates and reconnects.
Fresher cached reads
Newer releases let the cache answer even “give me the latest” requests safely, and stream big lists instead of building them in memory.
You never switch the watch cache on in a typical cluster; it is on by default. The skill is in understanding it, so that your own programs use it well instead of fighting it.
2Core Concepts: The Vocabulary You Need
A dozen words appear again and again in this topic. Learn them once and the rest reads easily.
| Term | What it means | Everyday picture |
|---|---|---|
| etcd | The database that durably stores every object in the cluster. | The locked vault with the master record book. |
| List | A request that returns all objects of a kind, as a snapshot. | “Show me everything on the shelf right now.” |
| Watch | A long-lived request that streams each change as it happens. | A live feed of arrivals and departures. |
| resourceVersion | A marker number on every object and list that says “this is the state as of this point in time.” | The page number in the master book. |
| Watch cache | The API server’s in-memory copy of objects plus a short history of changes. | The desk copy and the pinned list of recent edits. |
| Event | One change: added, modified or deleted. | One line on the change list. |
| Bookmark | A small message saying “you are caught up to version N,” with no object change. | A librarian nodding: “nothing new, we’re at page 4100.” |
| Compaction | etcd forgetting old versions to save space. | Shredding old pages of the book. |
| 410 Gone | The error that says “that version is too old; start over.” | “That page was shredded; take a new copy.” |
| Informer | A client-side helper that lists, watches and keeps a local cache. | A visitor’s personal notebook that stays updated. |
| Reflector | The part of an informer (or of the server’s cache) that does the list-and-watch. | The person who copies changes into the notebook. |
| Quorum read | A read that etcd confirms is the very latest by asking a majority of its members. | Phoning the head office to double-check. |
List and watch: the two basic verbs
Almost everything a controller does starts with two verbs. List returns a full snapshot, such as “all Pods in this namespace.” Watch returns a stream: it keeps the connection open and sends a small event whenever something changes. A smart client lists once to get the starting picture, then watches from that point on, so it never has to list again unless something breaks. This simple pairing is the heart of Kubernetes, and the cache exists to make it cheap.
resourceVersion: the clock of the cluster
Every object carries a resourceVersion, a text value the server sets whenever the object changes. It is closely tied to the internal revision counter of etcd, which ticks upward for every write anywhere in the database. Lists carry one too, meaning “this snapshot is as of this version.” Watches use it to say “start streaming from after this version.” It works like a bookmark in a long book, letting a client resume exactly where it stopped.
Although it looks like a number, your code must treat resourceVersion as a meaningless label, to be copied and compared for equality only. The format is an implementation detail and is allowed to change.
A news website shows an “updated at” stamp. If you remember the stamp from your last visit, you can ask “what happened since 10:42?” and get only the new stories. If you lost your stamp, you must reload the whole front page. A resourceVersion is that stamp for the cluster.
3Architecture: Where the Watch Cache Sits
The watch cache is not a separate server. It lives inside every API server process, between the web endpoints and etcd.
flowchart LR
ETCD["etcd: durable source of truth"] -->|one watch per resource type| REF["Reflector inside the API server"]
REF --> ST["In memory store: latest copy of every object"]
REF --> RB["Event window: ring buffer of recent changes"]
ST --> LS["Serve LIST from memory"]
RB --> WS["Fan out WATCH events to many clients"]
LS --> C1["kubectl and controllers"]
WS --> C2["kubelets, scheduler, controllers"]
Read the diagram from left to right. At the start sits etcd. For every kind of object, such as Pods or Services, the API server runs a reflector that lists everything once from etcd and then keeps one watch open. Whatever arrives is written into two places. The first is the store, an in-memory table holding the latest version of every object of that kind. The second is the event window, a fixed-size ring of the most recent changes. Clients never talk to etcd for these requests. Lists are answered from the store, and watches are fed from the event window and from live events.
The reflector
Lists all objects of a kind from etcd, then holds one watch open. It is the only component that talks to etcd for this resource’s reads.
The store
A thread-safe in-memory table of the newest copy of every object, indexed so that lookups by name, namespace or common fields are quick.
The event window
A ring buffer of recent changes, each tagged with its resourceVersion. It lets a client resume a watch from a recent point without a fresh list.
Watchers
One small object per connected client. Each has a filter (namespace, labels, fields) and a short buffer of events waiting to be sent.
The dispatcher
Takes each new event and hands it to every interested watcher. Serialization is shared, so the object is converted to bytes once rather than once per client.
Per-resource settings
Operators of self-managed clusters can tune the cache per resource type, or even disable it for one, using server flags.
One cache per API server
A production cluster runs several API servers for reliability, usually three or more, behind a load balancer. Each API server has its own watch cache, filled from the same etcd. The caches are not shared and not synchronised with each other. They all converge to the same truth, but at any instant one may be a fraction of a second behind another. This fact explains several surprising behaviours later in the guide.
What gets cached, and what does not
The cache holds the objects that clients list and watch through the main API: built-in kinds and custom resources. It does not cache the results of arbitrary requests, and it does not change write behaviour. Every create, update and delete goes straight to etcd, which remains the only place where the truth is decided. The cache hears about the result a moment later through its watch.
A newspaper has one printing plant (etcd) and several regional distribution depots (the API servers). Each depot receives the same stream of pages and keeps a stack of the latest edition. Readers buy from the nearest depot. A depot may be a few seconds behind another if a truck is delayed, but a reader who wants to publish a letter always sends it to the printing plant.
Production example: thousands of kubelets, one etcd watch
In a large cluster every kubelet keeps a watch on Pods filtered to its own node. Thousands of such filtered watches exist at once. Without the cache, each would cost etcd a separate watch on the same data. With the cache, etcd sees a single watch on Pods, and the API server filters and fans out the events in memory. This one design choice is a major reason Kubernetes reaches the scale it does.
4Internal Working: How the Cache Is Fed and Serves
Let us follow one change, from the moment it is written to the moment a distant client hears about it.
A write lands in etcd
Someone updates a Pod. The API server writes it to etcd, which assigns a new revision. This is the only step that must be strictly ordered and durable.
etcd announces it
The API server’s single watch on Pods receives the new state, tagged with that revision.
The cache updates itself
The reflector replaces the Pod in the store and appends an event to the ring buffer. The cache’s “current version” moves forward.
The dispatcher picks the audience
It checks which watchers care. A kubelet watching only its own node ignores Pods for other nodes. For common filters, such as the node name of a Pod, the cache keeps an index so it does not even have to test every watcher.
Serialization is shared
The object is turned into bytes once and reused for every interested client, instead of repeating the work for each.
Clients receive the event
Each watcher writes the event to its network connection. The client updates its own local copy and runs its handlers.
The ring buffer and why it is short
The event window holds only recent changes, not the full history. This is deliberate. Memory is limited, and a client that has been away for a long time is better off taking a fresh snapshot than replaying thousands of old events. The window starts small and grows when changes arrive quickly, up to a large upper limit, so busy resource types such as Pods get a longer memory than quiet ones such as Namespaces. Self-managed clusters can also set sizes by hand.
Think of the ring buffer as the pinned change list on the library desk. It holds the last few dozen or hundred edits. If a visitor says “I last saw version 4000,” and the oldest line on the list is version 4020, the librarian cannot tell them what happened between 4000 and 4020. They must take a new copy of the whole record.
Slow listeners are cut off
Each watcher has a small buffer for events waiting to be sent. If a client is too slow to read its connection, perhaps because it is busy or the network is poor, its buffer fills up. The cache waits briefly, then closes that watch rather than letting one slow client hold up everyone else. The client sees its connection drop and must reconnect. This is a feature: it protects the many from the few.
Watches do not last forever
Every watch has a time limit chosen randomly in a window of roughly half an hour to an hour by default. When the limit expires, the server ends the watch, and the client reconnects and resumes from its last version. The randomness is important. If all watches expired at the same moment, every client in the cluster would reconnect at once, causing a stampede. Spreading the expiry times smooths the load.
Starting up
When an API server starts, its caches are empty. Each reflector must list its resource from etcd before the cache can serve anyone. For a few seconds, requests for a still-filling cache may be slow, or may get a “try again soon” reply (HTTP 429). The server’s readiness check stays unhealthy until its caches are ready, so a load balancer should not send traffic to it too early.
A restaurant’s pass between the kitchen and the dining room takes each plate once, but it can serve twenty tables from the same tray. The kitchen (etcd) cooks each dish once. The waiter at the pass (the dispatcher) delivers copies to everyone who ordered it. A diner who walks away without taking their plate is eventually told to reorder at the counter, so the line stays moving.
If your tool keeps being disconnected after many minutes, that is normal. Watches are meant to end. A well-written client reconnects quietly and loses nothing.
5Asking the Right Way: How resourceVersion Controls Where You Are Served
The same list request can be answered from etcd or from the cache. A single parameter decides which.
Every list or get request may include a resourceVersion setting, together with an optional resourceVersionMatch setting. Together they say how fresh the answer must be. The server uses them to decide where to read from. Understanding this table is one of the most valuable things you can learn about the API server, because it explains both great performance and nasty surprises.
flowchart TD
R["LIST request arrives"] --> Q1{"resourceVersion given?"}
Q1 -->|"zero"| A["Served from the watch cache, any age"]
Q1 -->|"not set"| B["Needs the latest state"]
B --> Q2{"Consistent read from cache enabled?"}
Q2 -->|yes| C["Check latest etcd revision, wait for the cache, serve from cache"]
Q2 -->|no| D["Quorum read from etcd"]
Q1 -->|"specific number"| Q3{"Match mode"}
Q3 -->|"NotOlderThan"| E["Cache serves once it has caught up"]
Q3 -->|"Exact"| F["Served from etcd"]
| Setting | Meaning | Served from | Freshness |
|---|---|---|---|
| Not set | “Give me the most recent state.” | etcd with a quorum read (or the cache on newer versions that support consistent cached reads). | Latest possible. |
0 | “Any version is fine, as fast as possible.” | The watch cache. | Possibly a little stale, never older than what the cache has. |
A specific value with NotOlderThan | “At least as new as this.” | The watch cache once it has reached that version. | At least the stated version. |
A specific value with Exact | “Exactly as of that version.” | etcd. | Precise point in time. |
What this means in practice
A request with no version is the safest-looking choice, because it promises the latest data. But it is also the most expensive, because in the traditional design it forces a trip to etcd, which then has to read and return every object. A request with version zero is cheap, because the cache already has the answer in memory. For a client that is about to start watching, slightly stale data is fine: the watch will deliver any change that happened since the snapshot, so the client catches up within moments.
That is exactly why well-written controllers ask for version zero on their first list. A thousand controllers starting together after a restart cost almost nothing to the cache. A thousand controllers asking for the latest from etcd could knock the database over.
Pagination and the cache
Large lists can be requested in pages using limit and a continue token. This keeps memory use low because the server returns, say, 500 objects at a time. Traditionally, however, a list with version zero ignores the limit and returns everything from the cache in one piece, while a paged list is answered from etcd. Also, a continue token points into a consistent view in etcd, and because etcd compacts old versions every few minutes by default, a slow client may find its token expired and receive a 410 error. That is a signal to restart the listing, not a bug.
A loop that runs a list with no version set, over and over, against a cluster with a hundred thousand Pods, is one of the most common ways to hurt an API server. Each call can transfer hundreds of megabytes and make the server build the whole response in memory.
Watch has its own rules
| Watch version | Behaviour |
|---|---|
| Not set | Start with a synthetic “added” event for every existing object, then stream changes from the most recent point. |
0 | Same idea, but may begin from any recent state held by the cache. |
| A specific value | Stream only changes after that version. If it is older than the event window, the server replies 410 Gone. |
Ordering from a restaurant, you can say “bring whatever is ready” (version zero, instant), “bring the freshest dish, I’ll wait while the chef checks” (not set), or “bring the dish exactly as the menu described it last Tuesday” (exact). The first costs the kitchen nothing; the others each add work.
6The Watch Lifecycle: Bookmarks, Resumes and 410 Gone
A good client lives in a loop: list, watch, reconnect, and only occasionally start over. Here is that loop in detail.
flowchart TD
S["Informer starts"] --> L["LIST: get full state and its resourceVersion"]
L --> W["WATCH from that resourceVersion"]
W -->|events arrive| U["Update local cache, call handlers"]
U --> W
W -->|connection drops| W2["Resume watch from last seen version"]
W2 -->|version still in window| W
W2 -->|410 Gone: too old| L
Follow the diagram from the top. The informer starts by listing the full state and noting the list’s version. It then opens a watch from exactly that version, so no change falls into the gap between the two calls. Events arrive and update the local copy. If the connection drops, whether because of a timeout, a network blip or an API server restart, the informer reconnects and asks to resume from the last version it saw. If that version is still inside the server’s event window, the stream picks up as if nothing happened. If it is too old, the server answers 410 Gone, and the informer returns to the top and lists again.
Bookmarks: keeping the marker fresh
Imagine you are watching a quiet resource where nothing changes for hours. Your saved version stays old, while the rest of the cluster moves on and the event window slides forward. When your watch finally drops, your marker is far behind, the server answers 410 Gone, and you must perform a full, expensive list for no good reason.
Bookmarks fix this. A client that asks for them periodically receives a tiny event containing nothing but the newest version. The informer updates its saved marker, so a later reconnect starts from a recent point and nearly always lands inside the window. Bookmarks cost almost nothing, but they save a great deal of unnecessary full lists in large clusters.
You are on a long train journey and your friend texts “still at page 200, nothing new” every few minutes. When you finally look up, you know exactly where to restart reading instead of re-reading the entire book.
The pieces of a client informer
Reflector
Runs the list-and-watch loop and pushes every change into a queue.
Delta queue
Holds changes in order, so handlers see events one object at a time and nothing is lost.
Indexer (local cache)
The client’s own copy of every object, searchable by name, namespace or any index you add.
Lister
A read-only view over the local cache. Controllers read from here and never hit the API server for ordinary lookups.
Shared informer
One informer per resource type shared by many controllers in the same program, so only one watch is opened.
Resync
An optional timer that replays the local cache to handlers. It does not call the API server again; it helps handlers retry past failures.
Two caches, one story
It helps to see that there are two layers. The API server’s watch cache protects etcd from clients. The client’s informer cache protects the API server from the program’s own code. A controller that reads a thousand times a second does so from its local memory, and only the one watch connection touches the API server. Together the two layers turn a potentially huge amount of query traffic into one steady stream of changes.
When a client relists, it usually asks for a snapshot “not older than” the newest version it has already seen. This stops it from being served by a lagging API server and going backwards in time, a subtle problem we discuss in the chapter on high availability.
7Newer Improvements: Consistent Cached Reads and Streaming Lists
The basic design has served Kubernetes for years, but two newer ideas close its biggest gaps. Check which versions of your cluster support them.
Consistent reads served from the cache
Recall the awkward choice from Chapter 5. A request for “the latest state” had to go to etcd, which is slow and heavy, while a request for “any state” came from the cache but could be stale. Newer Kubernetes releases offer a clever middle path, usually described as consistent reads from the cache.
It works like this. When a request asks for the latest data, the API server asks etcd only one small question: “what is your newest revision number right now?” That is a tiny, cheap query compared with reading all objects. The API server then waits until its own watch cache has caught up to that revision, which normally takes a few milliseconds, and answers the full list from memory. The result has the same freshness guarantee as a direct etcd read, but the heavy lifting is done by the cache. Large lists that used to load etcd now load only the API server’s memory.
You want to be sure the departure board is up to date. Instead of phoning the airline for every flight (the heavy etcd read), you phone once to ask “what is the current update number?” and then check that the board shows that number. If it does, you trust the board for the whole list. One short call buys you the confidence to use the nearby copy.
Streaming lists
A normal list request builds the entire response in memory before sending the first byte. For a very large resource, such as a hundred thousand Pods, this means the API server must hold a gigantic temporary buffer, and a handful of simultaneous lists can push it out of memory. After a restart, when every client relists at once, the spike can be dramatic.
The newer approach, often called streaming list or watch-list, replaces the big list with a special watch. The client opens a watch and asks the server to send the initial state as a series of “added” events, followed by a marker event saying “the initial state is complete.” The server sends objects one by one, straight from the cache, without building a huge buffer. Memory use stays flat, and clients can begin processing objects as soon as the first ones arrive.
| Technique | Problem solved | Who does the work |
|---|---|---|
| Consistent cached reads | Fresh reads were slow and heavy on etcd. | The API server, transparently. |
| Streaming lists | Large lists caused memory spikes on the API server. | Client libraries and the API server together. |
| Bookmarks | Quiet watches fell out of the event window. | The API server sends; the client records. |
| Pagination | One response was too large. | The client asks for pages. |
Benefits of the newer techniques
- Much lighter load on etcd for fresh reads.
- Flat, predictable API server memory during relists.
- Fewer outages caused by restart stampedes.
- No change needed in how most kubectl users work.
Things to keep in mind
- Availability depends on your Kubernetes version and its feature settings.
- Older client libraries may not ask for streaming lists.
- The cache must be healthy; if it lags, consistent reads wait.
- Behaviour details have changed between releases, so read the notes for your version.
On a managed service such as Amazon EKS, Google GKE or Azure AKS, the provider chooses these settings when it upgrades the control plane. Your job is to keep client programs on recent library versions so that they take advantage of whatever the server offers.
8Other Caches Inside and Around the API Server
The watch cache gets the headlines, but a request passes through several other small caches, each saving work in its own corner.
flowchart LR
CL["Client side: discovery cache, informer cache"] --> AU["API server: authentication token cache"]
AU --> AZ["Authorization decision cache"]
AZ --> AD["Admission and schema validators, compiled once"]
AD --> WC["Watch cache and shared serialization"]
WC --> ET["etcd"]
Look at the diagram from left to right. Each layer exists because the step it protects is repeated thousands of times. Asking a remote identity service “who is this token?” for every request would be wasteful, so the answer is remembered briefly. Compiling a validation schema for every write would be wasteful, so it is compiled once and reused. The farther right a cache sits, the closer it is to the data, and the more it matters for scale.
Authentication cache
When the server checks tokens with an outside webhook, it remembers the answer for a short time (two minutes by default), so the same token does not trigger a call per request.
Authorization cache
Answers from an authorization webhook are remembered too: “allowed” for about five minutes and “denied” for about thirty seconds by default. Denials are kept short so that newly granted access works soon.
Serialization cache
Objects are converted to bytes once and the bytes are shared among all watchers receiving the same event.
Compiled schemas and rules
Custom resource schemas and CEL validation rules are prepared once when a CRD changes and reused for every request.
Discovery and OpenAPI
The list of available APIs and their shapes is served from prepared documents. The newer aggregated discovery bundles it into far fewer requests.
kubectl’s disk cache
The command-line tool saves the API list on disk, in a cache folder under your home directory, and reuses it for hours, so common commands avoid dozens of start-up calls.
Why the short times matter for security
A cache that remembers “allowed” for five minutes means that if you remove someone’s access, they may still get through for up to five minutes, but only when the decision came from an authorization webhook. The built-in role-based access control (RBAC) checker runs in the API server using its own up-to-date in-memory view of roles, so it reacts almost at once. Know which authorizer your cluster uses before you promise anyone “instant revocation.”
The cache-bypassing request: write and delete
No cache sits in front of writes. Every create, update, patch and delete is validated, passes through admission, and is committed to etcd immediately. This is what keeps the cluster’s truth in one place. The only subtlety is that after your write returns, your next read from a cache may not yet show it, because the cache learns of the change through its own watch a moment later. The API server’s reply to a write includes the new object and its version, so a client that needs to see its own change can use that reply.
A busy hotel does not phone the bank every time a guest taps their key card at a door. It keeps a quick list of valid cards for the next few minutes. The list is refreshed often enough that a stolen card stops working soon, yet not so often that the bank is flooded with calls. Authentication and authorization caches strike the same balance.
The watch cache holds objects in memory in readable form. Encryption at rest protects data stored in etcd, but not what the API server keeps in its own memory. Protect control-plane machines accordingly, and remember that anyone allowed to list or watch Secrets can read them all.
9Client-Side Caching: Controllers, Operators and Stale Reads
If you write or run controllers, your own program has a cache, and it behaves differently from what many people expect.
Reading from your own memory
Frameworks that help people write controllers and operators, such as controller-runtime, give you a client whose reads come from the local informer cache by default and whose writes go to the API server. This is wonderful for performance: a controller can read the objects it cares about thousands of times per second without sending a single request. It also hides a trap.
The read-after-write trap
Your controller creates an object
The write goes to the API server and succeeds.
It immediately reads it back
The read comes from the local cache, which has not yet received the matching event from the watch.
The cache says “not found”
The controller concludes the object is missing and creates it again, or reports an error.
The event arrives a moment later
Now the cache is correct, but the duplicate was already attempted.
The controller was not wrong. It simply read from a cache that is eventually consistent, meaning it will become correct soon, but not instantly. This is the same kind of lag you see between regional depots in the newspaper analogy.
How careful programs deal with it
Design for eventual truth
Make every action safe to repeat. If “create” finds the object already exists, treat that as success, not as failure.
Keep what you just wrote
Use the object returned by the write instead of re-reading it a millisecond later.
Remember expectations
Built-in controllers record “I just asked for three Pods” and wait until the cache shows them before counting again.
Use a direct reader sparingly
For rare, critical checks, read straight from the API server, accepting the extra cost.
Limit what you cache
Cache only the kinds and namespaces you need, with label filters where possible. Every cached object costs memory.
Use optimistic concurrency
Updates carry the version they were based on. If someone else changed the object first, the server answers with a conflict, and your code reads again and retries.
The cost of a cache you never wanted
A controller that reads Secrets or ConfigMaps through its cache will, by default, cache all of them cluster-wide, even if it needs three. In a large cluster that can mean hundreds of megabytes of memory and a heavy initial list. The cure is to restrict what the informer watches using namespaces, labels or fields, or to fetch the few objects directly when needed.
You post a letter and then immediately check your own mailbox for a reply. Of course it is empty; the letter has not even arrived yet. A sensible person waits for the answer to show up instead of posting the letter again. Your controller’s local cache is the mailbox, and the watch is the postman.
Production example: kubectl’s quiet helpers
When you run kubectl get pods on a cluster with thousands of Pods, the tool asks the server in pages of a few hundred objects at a time by default, which keeps memory low on both sides. And thanks to its on-disk discovery cache, it does not repeat the long start-up conversation of finding which APIs exist every time you type a command.
10The Foundation: etcd, Revisions and Compaction
Everything above rests on how etcd stores history. A little knowledge here explains most of the strange errors you will meet.
A database that keeps versions
etcd is a key-value database with a special property: it never overwrites a value in place. Instead, every change creates a new revision, a global counter that goes up by one for each write anywhere in the database. The older versions stay readable for a while. This design is called multi-version concurrency control, or MVCC, and it is what lets a client say “show me the state as of revision 4000,” and lets a watch say “tell me everything after revision 4000.”
The resourceVersion on a Kubernetes object is derived from these revisions. That is why a watch can resume reliably: the position in the stream is simply a place in etcd’s ordered history.
Compaction: forgetting on purpose
If etcd kept every revision forever, the database would grow without end. So old revisions are periodically removed in an operation called compaction. In a standard Kubernetes setup, the API server asks etcd to compact about every five minutes. After compaction, requests for versions older than the compaction point fail with an error that the server turns into 410 Gone. This is precisely why a paged list can expire if the client is too slow, and why the cache’s event window is short.
Compaction marks space as reusable but does not shrink the file on disk. Operators occasionally run defragmentation to give that space back. etcd also has a configured size limit; if the database reaches it, etcd stops accepting writes until space is freed, which makes the whole cluster read-only. Watching database size is a basic operational duty.
Think of an accountant who never erases numbers but crosses them out and writes a new line, so every past balance can be traced. Every few minutes, a clerk shreds the oldest pages to keep the filing cabinet from overflowing. Anyone asking about a balance from before the shredding is told: “that page is gone.”
The cost of large objects and frequent updates
Because every update writes a whole new version, large objects that change often create heavy traffic. A 1 megabyte object updated ten times a minute produces a steady stream of megabytes through etcd, through the watch cache, and out to every watcher. This is why the community pushes for small objects, and why splitting data into several smaller objects is often better than one giant one.
Events deserve their own storage
The Kubernetes Event objects, which record things like “Pod scheduled” and “image pulled,” are numerous and short-lived. In large self-managed clusters, operators often store them in a separate etcd so that their churn does not slow down the main database. Event objects also expire automatically after a time-to-live, one hour by default.
| etcd concept | Effect on Kubernetes |
|---|---|
| Revision | Provides the resourceVersion and the order of events. |
| MVCC history | Allows watch resumes and consistent paged lists. |
| Compaction | Causes 410 Gone for versions that are too old. |
| Defragmentation | Reclaims disk space after compaction. |
| Database size limit | If reached, the cluster stops accepting writes. |
| Request size limit | Caps how large a single object can be, around one and a half megabytes by default. |
On a managed Kubernetes service, the provider runs etcd, compaction and defragmentation for you. You still control the number, size and update rate of the objects you create, and that is where most trouble starts.
11Data Flow and Lifecycle: A Pod’s Story Through the Caches
Let us watch a single new Pod travel through the system and see which cache each component reads from.
You create a Deployment
kubectl sends a write to the API server. It passes authentication, authorization and validation, and is committed to etcd.
The Deployment controller hears about it
Its informer, fed by a watch on the API server’s cache, receives the new Deployment. It creates a ReplicaSet, which is a write.
The ReplicaSet controller creates Pods
Again it was notified by a watch event. It writes new Pods with an empty node name.
The scheduler wakes up
Its watch on Pods delivers the unscheduled Pods. It reads node information from its local cache, picks a node, and writes a binding.
The kubelet on that node hears about it
Its watch is filtered to its own node, so only now does the Pod appear in front of it. It also watches the Secrets and ConfigMaps its Pods use, then starts the containers.
Status flows back
The kubelet writes the Pod’s status. The write goes to etcd, then into the cache, then out to everyone watching, including the Deployment controller, which updates the Deployment’s status.
Networking learns about it
An endpoint slice controller notes the ready Pod and updates the endpoint slices. Every kube-proxy and DNS server watching them updates its routing.
Notice the pattern. Components almost never talk to each other directly. Each one writes its result to the API server and reads what others wrote by watching. The watch cache is the shared notice board that makes this whole conversation possible, and it is why the system can add components without changing existing ones.
Who watches what
| Component | Watches | Typical filter |
|---|---|---|
| Scheduler | Pods, nodes, volumes | Pods without a node, all nodes. |
| Kubelet | Pods, plus Secrets and ConfigMaps used by its Pods | Its own node name. |
| kube-proxy | Services and endpoint slices | Whole cluster, often with label rules. |
| Controller manager | Dozens of kinds, each with its own informer | Mostly cluster-wide. |
| Ingress or gateway controllers | Ingresses, Services, endpoint slices, Secrets | Often cluster-wide. |
| Operators | Their own custom resources and what they own | Whatever the author set. |
Making the traffic smaller
Node leases
Instead of rewriting the large Node object every few seconds as a heartbeat, each kubelet updates a tiny Lease object roughly every ten seconds. The big Node status is refreshed only when something changes or after several minutes. This removes a huge amount of watch traffic.
Endpoint slices
The old Endpoints object held every address of a Service in one record. One Pod changing meant resending the whole record to every kube-proxy. Slices split the addresses into groups of up to a hundred by default, so only a small piece changes.
Immutable ConfigMaps and Secrets
Marking them immutable lets the kubelet stop watching them, which cuts load noticeably in clusters with many.
Field selectors on watches
Filtering on the server, such as by node name, means each client receives only what it needs.
Production example: the Endpoints problem at scale
In a Service with several thousand Pods, every Pod start or stop used to change one giant Endpoints record, and that whole record was sent to every kube-proxy in the cluster. With thousands of nodes this multiplied into gigabytes of traffic during a rolling update. Endpoint slices were created to cut this exactly, and they are now the default for clusters everywhere, including those on Amazon EKS and other managed services.
12Performance and Scalability
The watch cache makes large clusters possible, but it is not free. Here is where its costs live and how to keep them small.
Memory is the main price
The watch cache keeps a copy of every cached object in the API server’s memory. The memory needed is roughly the total size of all objects of the cached kinds, plus the event window, plus working space for serving requests. A cluster with a hundred thousand Pods, each a few kilobytes, already needs gigabytes. Because every API server keeps its own cache, the cost is multiplied by the number of API servers.
This is why the size and number of your objects matters so much. A Secret that holds a large file, a ConfigMap that stores a whole data set, or a custom resource that records thousands of status entries each consumes memory on every API server, and every update is pushed to every watcher.
The three multipliers of trouble
Number of objects
Fifty thousand ConfigMaps cost memory even if they never change. Clean up what is no longer used.
Size of objects
A single event for a large object is sent to every interested watcher. Ten watchers of a 500 kilobyte object means 5 megabytes of traffic for one update.
Rate of change
An object updated once a second with a hundred watchers creates a hundred messages a second. Status fields that change constantly, like timestamps updated every second, are expensive.
Protecting the server from clients
The API server also limits how much any one client can ask. A mechanism called API Priority and Fairness sorts incoming requests into queues by importance and by who sent them, so a misbehaving program cannot starve the kubelets or the scheduler. A list that is cheap thanks to the cache uses less of its quota than one forcing a trip to etcd. When your program is told to slow down (HTTP 429), it should back off instead of retrying immediately.
Tuning options
| Lever | Where you control it | Effect |
|---|---|---|
| Per-resource cache size | Self-managed API server flags | Longer event memory for busy resources, at memory cost. |
| Disable the cache for a resource | Self-managed API server flags | Saves memory, pushes reads to etcd. Rarely wise. |
| Separate etcd for Events | Self-managed API server flags | Isolates high-churn data. |
| Request concurrency limits | Self-managed API server flags and flow control objects | Controls how much load is admitted. |
| Client-side filters and informers | Your own programs | Smaller caches and fewer events. Works on any cluster. |
| Object design | Your own manifests and operators | The biggest lever of all. |
On Amazon EKS, Google GKE and Azure AKS you cannot change API server flags. The provider sizes and scales the control plane. Your influence is entirely on the client side: how many objects you create, how big they are, how often they change, and how your programs list and watch.
A town’s notice board can hold only so many notices. If neighbours start pinning large posters and replacing them every minute, the board becomes crowded and everyone walking past has to read the changes. The board is not broken; the notices are too big and change too often. The fix is to post short, stable notices and send detailed documents by another route.
13High Availability and Consistency: Many API Servers, Many Caches
A reliable cluster runs several API servers. Each has its own cache, and that creates some subtle behaviours worth understanding.
Caches that are close, not identical
All API servers read from the same etcd, so they agree on the truth. But each learns about changes through its own watch, with a delay that is normally a few milliseconds and occasionally longer. During that delay, API server A may know about a change that API server B has not yet received. A client that reads from A and then from B can see the cluster appear to move backwards in time. This is sometimes called a time travel problem.
Two screens in an airport show the same flight. One updates at the moment the plane is delayed; the other lags by half a minute. A passenger who walks from the first screen to the second sees the flight go from “delayed” back to “on time,” and then to “delayed” again. Nothing is wrong with the flight; the screens are just updated at slightly different moments.
How clients protect themselves
Well-written client libraries remember the newest version they have seen. When they relist, they ask for a snapshot “not older than” that version. A lagging API server then waits until its cache has caught up, instead of returning stale data that would make the client go backwards. This is one reason to keep client libraries current, and to avoid hand-written loops that ignore resourceVersion.
The restart stampede
When an API server restarts, or a load balancer shifts traffic, thousands of watches drop at nearly the same instant. Every client reconnects and tries to resume. Those whose saved versions are still inside the event window resume cheaply. Those who are too far behind must relist, and if they list with the latest-state setting, the combined load on etcd can be severe. Bookmarks, version zero lists, random watch timeouts and streaming lists all exist to soften this moment.
One API server restarts
Its watchers disconnect. Its cache is empty and must refill from etcd before it can serve.
Clients reconnect elsewhere
The load balancer sends them to the remaining servers, whose caches are warm. Most resume without a relist.
The restarted server warms up
It reports “not ready” until its caches are filled, so the load balancer holds back traffic.
It rejoins
New connections arrive in a smooth trickle instead of a rush. Rolling upgrades repeat this one server at a time.
Upgrades and the human factor
During a control-plane upgrade, API servers briefly run different versions, and so may differ in which caching features they offer. A good rolling upgrade restarts servers one at a time and waits for each to become ready before moving on. Resist the urge to restart them all at once to save time. The brief outage of the whole control plane, followed by a stampede of every client in the cluster, is far worse than a slow upgrade.
Point health checks at the API server’s readiness endpoint, not just at its port. A server that has opened its port but not yet filled its caches will answer slowly or with errors, and a good readiness check keeps it out of rotation until it is truly ready.
14Security Considerations
A cache is a second copy of your data, and copies deserve the same protection as originals.
List and watch are powerful
Permission to list or watch a resource lets someone see every object of that kind, including every Secret if you allow Secrets. Grant these verbs carefully, as narrowly as possible, and prefer namespace-level roles to cluster-wide ones.
Plain text in the cache
Encryption at rest covers etcd’s storage, but objects live decrypted in API server memory and in client informer caches. Treat control-plane hosts and memory dumps as sensitive.
Permission caches take time
Webhook authorizers remember answers briefly, so changes to access can take a few minutes to apply in the worst case.
Expensive requests are an attack
A user who may list everything can deliberately or accidentally overload the server. Priority and fairness limits blunt this, but least privilege prevents it.
Log who listed what
Audit logs record list and watch requests with the user, resource and result, which helps trace both attacks and honest mistakes.
Your operators hold secrets too
A controller that caches all Secrets cluster-wide holds a copy of all of them in its own memory. Narrow its scope to what it truly needs.
Do not confuse a cache with a permission boundary. A filter on a watch, such as a label or a node name, is a convenience for performance, not a security control. Real access limits come from role-based access control.
For a healthy security posture, give each program its own identity, with the smallest roles that let it do its job. Review which accounts hold cluster-wide list or watch rights on Secrets, ConfigMaps and nodes. And keep control-plane access separate from ordinary user access, because a person who can read the API server’s memory can read everything the cache holds.
15Monitoring, Logging and Troubleshooting
The API server tells you how its caches are doing, if you know which numbers to read.
Metrics worth graphing
| Metric (simplified) | What it tells you | Worry when |
|---|---|---|
apiserver_request_duration_seconds | How long requests take, by verb and resource. | LIST or WATCH setup times jump. |
apiserver_request_total | Requests by verb and response code. | Rising 410, 429 or 5xx codes. |
apiserver_registered_watchers | Open watches per resource. | A sudden climb, often from a leaking client. |
apiserver_watch_events_total and size metrics | How many events and how many bytes go out to watchers. | One resource dominates traffic. |
apiserver_storage_objects | Number of objects stored, by resource. | Unexpected growth, such as runaway Events or ConfigMaps. |
| Watch cache capacity metrics | How large the event window has grown for each resource. | Capacity is pinned at its upper limit. |
etcd_request_duration_seconds | How slow etcd is for the API server. | Latency rises while cached reads remain fast. |
| Flow control rejection and queue metrics | Which clients are being limited. | Rejections for system components such as kubelets. |
| Process memory of the API server | How close the server is to running out. | Steady climb or sharp spikes during lists. |
Symptoms and likely causes
| Symptom | Likely cause | First step |
|---|---|---|
| Clients log “too old resource version” or 410 | A watch resumed from a version outside the event window, or a paged list outlived compaction. | Normal in moderation; check for slow clients or no bookmarks if constant. |
| Many 429 “try again” replies | Cache still warming, or flow control limiting a noisy client. | Find the client; add backoff; check restarts. |
| API server runs out of memory during relists | Many clients asking for huge lists at once. | Use version zero, pagination or streaming lists; stagger restarts. |
| A controller sees an object disappear and reappear | Reading from a lagging cache or a different API server. | Use resourceVersion correctly; update client libraries. |
| Controller creates duplicates | Read-after-write against its local cache. | Make actions idempotent; keep the returned object. |
| Watches disconnect every few seconds | Slow consumer, overloaded network, or a proxy cutting idle connections. | Check client speed and any in-between proxy timeouts. |
| Cluster becomes read-only | etcd reached its size limit. | Compact and defragment; find what is bloating it. |
A short troubleshooting routine
Look at request codes first
Is the trouble 410, 429 or 5xx? Each points in a different direction.
Find the biggest consumer
Which resource and verb take the most time or bytes? Often a single misbehaving program is responsible.
Identify the client
Audit logs and flow control metrics show which user or service account is calling.
Check object counts and sizes
Look for unexpected growth in a resource type, such as old Jobs or Events piling up.
Fix the cause, not the symptom
Add filters, bookmarks, pagination, backoff or cleanup to the client. Raising server limits is a last resort.
Production example: managed control planes
On Amazon EKS you cannot log in to the control plane, but you can read the API server’s own metrics through the Kubernetes API and send audit logs to CloudWatch. That is enough to see which user is making large lists and which resource is generating the most watch traffic, which is usually all you need to fix the problem on the client side.
Graph the number of open watches and the number of objects per resource over a month. Slow growth you can plan for; sudden steps usually point to a new tool or a change in how a team uses the cluster.
16Design Patterns, Anti-Patterns and Best Practices
The lessons of years of large clusters, boiled down into habits you can adopt on day one.
Patterns worth copying
List once, then watch
Use informers instead of polling. One list at start, then a steady stream of changes.
Share informers
One informer per resource type per program, shared by all its controllers.
Filter at the server
Use namespaces, labels and field selectors so the server sends only what you need.
Idempotent actions
Design every step so that repeating it is harmless. This turns stale reads from bugs into non-events.
Small, stable objects
Keep objects small. Put frequently changing data in separate small objects instead of one big one.
Back off on pressure
On 429 or errors, wait with growing, randomised delays before retrying.
Traps to avoid
What people do
They poll: a script lists all Pods every few seconds to check for changes.
Why it hurts
Each poll transfers the whole list, and many such scripts together generate heavy traffic. The data is also at best a few seconds old.
Do this instead
Use a watch through an informer. The server then sends only changes, and nothing is missed between checks.
What people do
They run lists with no version set, inside loops or at start-up of every replica, across the whole cluster.
Why it hurts
Each call can force a heavy read and a giant response. After an outage, all replicas do it together, and the stampede prolongs the outage.
Do this instead
Prefer informers; if you must list, ask for the cached version when slight staleness is acceptable, and use pagination or filters.
What people do
They store large, fast-changing data in a single custom resource or ConfigMap, such as a big table updated every second.
Why it hurts
Each update rewrites the whole object in etcd and is pushed to every watcher. Memory, bandwidth and etcd all suffer.
Do this instead
Keep Kubernetes objects small. Put bulky or fast-changing data in a database or object store and keep a reference in the object.
What people do
They assume that a read right after a write will show the write, and write the code without any retry or tolerance.
Why it hurts
Everything works in tests, then fails rarely and mysteriously in busy clusters, producing duplicates or false errors.
Do this instead
Assume eventual consistency. Use the returned object, check conflicts, and repeat safely.
Best practices checklist
Keep client libraries up to date
Newer versions use bookmarks, streaming lists and safe resume logic without any work on your part.
Scope every watch
Namespace, label and field filters for every informer, especially in operators that run in many clusters.
Clean up old objects
Set expiry on finished Jobs, remove stale ConfigMaps, and keep Event retention sensible.
Stagger restarts
Roll deployments and control-plane nodes gradually to avoid stampedes.
Load-test at real scale
Check your controller against a cluster with production-sized object counts, not a laptop-sized one.
Watch the watchers
Track open watch counts and API request rates from each of your own components.
Common mistakes and quick fixes
| Mistake | What you see | Fix |
|---|---|---|
| Treating resourceVersion as a number to compare | Odd bugs after format changes. | Use it only for equality and for passing back to the server. |
| Resync period set very low | Handlers run constantly with no real change. | Use a long period or none; resync is local anyway. |
| Informer for all Secrets cluster-wide | Large memory use and large initial list. | Narrow scope or fetch on demand. |
| Ignoring 410 errors | Controller stops receiving updates. | Relist and rewatch; use a library that does this for you. |
| Retrying immediately on 429 | Makes overload worse. | Back off with jitter. |
| Hand-written watch loops | Missed events, tight reconnect loops. | Use the standard informer machinery. |
| Giant ConfigMaps updated often | High API server memory and traffic. | Split them, or move data elsewhere. |
Advantages of the caching design
- Thousands of watchers cost etcd almost nothing extra.
- Fast reads and near real-time notification.
- Clean model: write to the API, learn by watching.
- Resilient: clients resume or relist automatically.
Disadvantages and trade-offs
- Heavy memory use on every API server and many clients.
- Eventual consistency surprises careless code.
- Short event window means relists are sometimes unavoidable.
- Restart stampedes need careful handling.
17Real-World and Industry Examples
The same few ideas show up in every big cluster. Here is where you can see them in the tools you already use.
The kubelet: one agent per node, one filtered watch
On every node of every cluster, including those on Amazon EKS, the kubelet watches Pods assigned to its own node and the Secrets and ConfigMaps those Pods use. Thanks to server-side filtering and the cache, thousands of kubelets can do this at the same time without ever bothering etcd individually.
Node leases and endpoint slices: lighter traffic by design
Two changes made large clusters much calmer. Node leases replaced constant rewrites of a big Node object with tiny heartbeat updates. Endpoint slices replaced one giant record per Service with small groups of addresses. Both reduce the number and size of events the cache has to dispatch, which is the cheapest kind of performance improvement.
Operators and GitOps tools: caches by the thousand
Tools like Argo CD keep an in-memory cache of the cluster resources they manage so that they can compare desired and actual state quickly. Operators built on controller-runtime keep informer caches for every kind they watch. In big clusters, the memory and initial-list cost of these caches is a daily concern, which is why they offer settings to narrow the namespaces and kinds watched.
Autoscalers and ingress controllers
Karpenter, Cluster Autoscaler, ingress controllers and service meshes all watch Pods, nodes, Services and endpoints. They rely on informers to see the cluster as it changes, and on the watch cache to make that cheap. When one of them is misconfigured to list without caching, platform teams often see it first as a spike in API server load.
Large operators’ lessons
Teams that run clusters with thousands of nodes commonly report that API server memory, watch fan-out and etcd size are the first limits they meet, long before CPU or networking. Their usual fixes are the ones in this guide: smaller objects, narrower watches, fewer needless updates, and careful client libraries.
A typical journey at a growing company
A team begins with a small cluster and a few scripts that run kubectl get in loops. As the cluster grows, the API server slows during busy hours. An engineer notices the scripts are the top callers and replaces them with a controller using a shared informer. Later, an operator is added that caches every Secret, and memory use jumps, so its scope is narrowed to a few namespaces. After a control-plane upgrade, a restart stampede causes a brief slowdown, so the team starts upgrading one server at a time and adds readiness checks. Finally they graph watch counts and object sizes monthly, and clean up old Jobs and Events. The cluster is now ten times larger, and the control plane is calmer than before.
18Frequently Asked Questions
19Summary and Key Takeaways
The Kubernetes API server is the front door of the cluster, and etcd is the vault behind it. The watch cache lets thousands of clients read and listen without ever crowding the vault: one watch per resource type feeds an in-memory store and a short window of recent events, and every client is served from memory. The resourceVersion marker decides where a read is answered and how a watch resumes. Clients add their own informer caches on top, and a handful of smaller caches handle authentication, permissions and serialization. Used well, this design lets a cluster reach thousands of nodes. Used carelessly, it produces memory pressure, stale reads and stampedes.
Key Takeaways
- One watch, many listeners. The API server opens a single etcd watch per resource type and serves all clients from an in-memory store plus a ring buffer of recent events.
- resourceVersion decides the path. Version zero is served from the cache, an unset version needs the latest state, and a stale marker leads to 410 Gone and a fresh list.
- List once, then watch. Informers, bookmarks and shared caches turn heavy polling into a light stream of changes, and your code should read from them, not from repeated lists.
- Expect eventual consistency. Reads from caches can lag a write by moments, and different API servers can differ slightly. Make actions repeatable and use the object returned by the write.
- Size, count and churn are the costs. Every object lives in memory on every API server and is pushed to every watcher, so keep objects small, clean up, and avoid constant status updates.
- Protect the fleet from stampedes. Use bookmarks, version-zero lists, streaming lists, rolling upgrades and readiness checks, and make clients back off on 429.
- Treat caches as sensitive and observable. Limit list and watch rights, remember that caches hold readable copies, and monitor request codes, open watches, object counts and memory.