resourceVersion and Optimistic Concurrency in Kubernetes
How Kubernetes lets people, tools and controllers edit the same objects at the same time without silently overwriting each other, explained from first principles with real-world analogies and no code required.
Picture a big whiteboard in a busy school hallway. Dozens of students and teachers walk up to it all day long, each one reading what is written and then adding, erasing or fixing something. Now imagine two people reading the same corner of the board at the same moment. One decides to rewrite the lunch menu and the other decides to rewrite the same menu a different way. Both walk away happy, but only the second rewrite survives, and the first person never finds out their work vanished. A Kubernetes cluster is that whiteboard, except the people are developers, deployment pipelines, autoscalers and hundreds of automated controllers, and they write thousands of times every minute. Kubernetes needs a clever, lightweight rule to keep that crowd from erasing each other. That rule is a small piece of text called resourceVersion, and the idea behind it is called optimistic concurrency. This guide explains both, step by step.
1The Lost Update Problem
Before learning the solution, it helps to feel the problem it solves.
Kubernetes is a shared workspace. The things inside it, such as Pods, Deployments, Services, ConfigMaps and Nodes, are called objects, and no single person or program owns them exclusively. A developer might change a Deployment from a terminal. A deployment tool might push a new version of that same Deployment a few seconds later. An autoscaler might adjust the number of copies while both of them are still working. A controller might write progress information back onto the object. All of these writers talk to one central program, the API server, which keeps every object safe in a database called etcd.
When many writers share one object, a famous bug becomes possible. Computer scientists call it the lost update problem. It happens in three small steps. First, writer A reads the object. Second, writer B reads the same object. Third, each writer changes its own private copy and sends it back. The writer who saves last wins, and the earlier change disappears without any warning. Nobody did anything wrong. Each writer performed a perfectly normal read, edit and save. The danger hides in the gap of time between the read and the write.
Two cooks each photocopy the same recipe card from the kitchen board. One cook writes “add more salt” on her copy, and the other writes “use less sugar” on his. Each pins their copy back on the board, replacing the original. The board now shows only the last note, and the first cook’s improvement is gone. Neither cook did anything silly. The board simply had no way to notice that the card had changed while they were scribbling.
sequenceDiagram
participant A as Writer A
participant B as Writer B
participant S as API Server
A->>S: Read the object
S-->>A: Copy 1
B->>S: Read the object
S-->>B: Copy 1
A->>S: Save my edited copy
S-->>A: Saved
B->>S: Save my edited copy
S-->>B: Saved, silently replacing A's work
Why this matters even more in Kubernetes
In an ordinary website, two people rarely edit the same record in the same second. Kubernetes is different because automation never sleeps. Controllers run in endless loops, always comparing the world as it is with the world as it should be, and fixing any difference. Several of them may care about the same object at once. That means collisions are not rare accidents. They are a normal, daily part of how the system works, and the design must handle them calmly, thousands of times a day, without human help.
A loud error is easy to fix because somebody notices it. A silent overwrite is far worse. A scaling change, a security setting or a label could vanish, and the cluster would keep running as if nothing happened until something broke much later.
2What Is resourceVersion?
A small piece of text on every object that works like a “last changed” stamp.
Every Kubernetes object has a section called metadata. Inside it you will find the name, the namespace, labels, annotations and several other bookkeeping fields. One of those fields is resourceVersion. It is a short string that the server changes every time the object is stored in a new form. You can think of it as a stamp that says “this is the exact edition of the object you are looking at.”
The way it is used is beautifully simple. When you read an object, the server hands you the object together with its current stamp. Later, when you want to save a change, you send the object back with the stamp you received. The server compares your stamp with the stamp on the stored object. If they match, nobody touched the object while you were working, so your change is accepted and a new stamp is created. If they do not match, somebody else got there first, so your change is refused and you are told to look again. No waiting, no locking, just one comparison.
A hospital keeps a chart at every bed with a “last updated” number in the corner. A nurse who wants to add a note first remembers the number she saw. Before writing, she checks that the number on the chart is still the same. If another nurse has updated the chart in the meantime, the number will be different, and she knows to read the new notes before adding hers.
Three facts to remember
First, resourceVersion is a string, not a number you should do arithmetic on. Second, it is opaque, which means its inside meaning belongs to the server and clients should only copy it and compare it for equality. Third, it is owned by the server. A client cannot make up a clever value to get its way. A client can only echo back a value it was previously given.
Its neighbours in the metadata
Several other metadata fields are easy to confuse with resourceVersion. Each answers a different question, so it is worth meeting them side by side.
resourceVersion
Changes on every stored change to the object, including labels, status and metadata. Answers: “Is my copy still the latest?”
uid
A unique identity given when the object is created and never changed. If an object is deleted and recreated with the same name, the uid is different.
generation
A counter that goes up when the desired state, the spec, changes. It ignores status updates, so it moves much more slowly than the stamp.
observedGeneration
Written by a controller into the status to say “I have already handled generation number N.” It lets readers know whether status is fresh.
creationTimestamp
The moment the object was created. Useful for age checks, but unrelated to concurrency.
managedFields
A record of which tool owns which fields. It powers server-side apply and is a separate idea from the version stamp.
| Field | Question it answers | Changes when | Set by |
|---|---|---|---|
| resourceVersion | Is this the latest stored edition? | Any stored change | API server |
| generation | Has the desired state changed? | Spec changes | API server |
| observedGeneration | Which desired state has the controller handled? | Controller finishes work | Controller |
| uid | Is this the same object or a replacement? | Never, for one object | API server |
The stamp is about the whole stored object, not about one field. Even a status update by a controller changes it, which is why a spec edit and a status write can collide.
3Where resourceVersion Comes From
The stamp is borrowed from a counter inside etcd, the database underneath Kubernetes.
Kubernetes keeps all of its objects in etcd, a distributed key-value database. etcd has a feature that makes concurrency control easy: it keeps one global counter called the revision. Every successful write anywhere in the database moves that counter forward by exactly one. Each stored key also remembers two special numbers, the revision at which it was created and the revision at which it was last modified.
In the usual setup, the API server uses the last-modified revision of an object’s key as that object’s resourceVersion. This explains why the stamps look like large numbers that seem to jump around. If you write a ConfigMap, then a Pod, then the same ConfigMap again, the revisions might be 101, 102 and 103. The ConfigMap ends up with stamp 103 and the Pod keeps stamp 102. Each object only changes its stamp when that object itself is written, but the counter underneath moves for everyone.
flowchart LR
W1["Write ConfigMap
counter becomes 101"] --> W2["Write Pod
counter becomes 102"]
W2 --> W3["Write ConfigMap again
counter becomes 103"]
W3 --> R["ConfigMap stamp: 103
Pod stamp: 102"]
Think of a deli counter where one ticket machine hands out numbers to everyone in the shop. Your friend buying cheese and you buying ham both draw tickets from the same roll, so your numbers are not consecutive even though you came together. A gap in the numbers does not mean something was missed. It only means other customers were served in between.
What the numbers do and do not tell you
Because the counter is shared, the stamp does not tell you how many times your object has changed. A stamp that jumped by a thousand simply means the cluster was busy with other objects. Likewise, a small difference between two stamps does not mean “almost the same.” The only safe question is whether two stamps are equal.
| etcd concept | Meaning in Kubernetes |
|---|---|
| Global revision | A cluster-wide counter that moves forward on every write |
| Create revision | The counter value when the object first appeared |
| Mod revision | The counter value at the object’s last change, used as its resourceVersion |
| Compaction | Old history is discarded, so very old stamps can no longer be resumed from |
Lists have a stamp too
When you ask for a list of objects, the answer carries one stamp for the whole list. It describes the moment the snapshot was taken. As you will see later, that stamp is the starting line for watching changes without missing or repeating anything.
Today the stamp often equals an etcd revision, but the Kubernetes documentation tells clients to treat it as opaque. Other storage layers, such as aggregated API servers, may produce different values, and the format may change in the future. Compare for equality only.
4Optimistic versus Pessimistic Concurrency
Two very different philosophies for sharing something that several people want to change.
There are two classic ways to stop people from overwriting each other. The first is called pessimistic concurrency. It assumes trouble is likely, so it makes every writer take a lock before editing. While one writer holds the lock, everybody else must wait their turn. The second is called optimistic concurrency. It assumes trouble is unlikely, so it lets everyone work freely and checks only at the very end whether somebody else changed the thing in the meantime. If someone did, the late writer simply starts that one edit again.
Imagine a single popular library book. The pessimistic way is to let one reader check it out while everyone else queues, even if the first reader falls asleep with it. The optimistic way is to let everyone photocopy the book and, when they hand in their notes, have the librarian confirm the notes were made from the latest edition. Readers who used an old edition just redo their notes quickly.
Why Kubernetes chose optimistic
Several reasons point in the same direction. Clients in a cluster come and go constantly, and any of them can crash at any moment. A crashed client holding a lock would block everyone else until some timeout rescued the situation, and picking good timeouts for thousands of different clients is hard. The API is a stateless HTTP interface, so holding a lock across several separate requests would be awkward. Most objects are read far more often than they are written, and two writers rarely hit the same object in the same instant, so the common case is “no conflict, no cost.” Finally, etcd natively supports a transaction that says “write this only if the key is still at revision N,” which is exactly the check that optimistic concurrency needs.
| Question | Pessimistic (locks) | Optimistic (version check) |
|---|---|---|
| Do writers wait? | Yes, in a queue | No, they proceed at once |
| What if a writer crashes? | Lock may stay stuck until a timeout | Nothing is held, so nothing is stuck |
| Cost when nobody collides | Still pays for locking | Almost free |
| Cost under heavy collisions | Orderly but slow | Wasted work and retries |
| Fit for distributed clients | Complicated | Natural |
Advantages
- No lock service to run, monitor or recover
- Crashed clients cannot block anyone else
- Very cheap when collisions are rare
- Simple for clients: read, change, save, retry on conflict
- Collisions are reported loudly instead of hidden
Disadvantages / Trade-offs
- Losers must redo their work
- Heavy contention on one object causes retry storms
- A slow writer can lose again and again
- Clients must be written to handle conflicts properly
Engineers sometimes call this pattern compare-and-swap: compare what I expect with what is there, and swap in my new value only if they match. Processors use the same idea to update shared counters without locks, and Kubernetes applies it to whole objects.
5Anatomy of a Conflict: The Life of an Update
Follow one update from the moment a client reads an object to the moment it succeeds or is turned away.
Understanding a conflict is easiest when you walk through it slowly. Suppose a deployment tool wants to add a label to a Deployment while an autoscaler is changing its replica count. Here is the whole story, one step at a time.
Read
The client asks the API server for the object and receives it together with its current stamp, say version X.
Think and edit
The client changes its private copy. This may take milliseconds or several seconds, and meanwhile the world keeps moving.
Send the update
The client sends the edited object back, still carrying version X in its metadata.
Compare
The server checks whether the stored object is still at version X. This check is made atomically against the database, so two writers cannot both pass it.
Accept or refuse
If the versions match, the change is stored and the client receives the object with a brand new stamp. If they differ, the client receives a 409 Conflict response and nothing is stored.
Recover
After a refusal, the client must read the latest object again, apply its intended change to that fresh copy, and try once more.
sequenceDiagram
participant C1 as Deployment Tool
participant C2 as Autoscaler
participant S as API Server
C1->>S: Read Deployment
S-->>C1: Object at version 7
C2->>S: Read Deployment
S-->>C2: Object at version 7
C2->>S: Update replicas, expecting version 7
S-->>C2: Accepted, now version 8
C1->>S: Update label, expecting version 7
S-->>C1: 409 Conflict, stored version is 8
C1->>S: Read Deployment again
S-->>C1: Object at version 8
C1->>S: Update label, expecting version 8
S-->>C1: Accepted, now version 9
What the error looks like
The refusal arrives as an HTTP 409 with a reason of Conflict. The human-readable message is usually something like “the object has been modified; please apply your changes to the latest version and try again.” The message is advice as well as a complaint, and it describes exactly the right recovery. Note that a 409 can also mean something else, namely that you tried to create an object whose name already exists. The reason field, AlreadyExists, tells the two apart.
What happens if you send no stamp at all
If an update arrives without a resourceVersion, the outcome depends on the kind of object. Many built-in resources allow an unconditional update, which means the server skips the check and the last writer wins. Some resources refuse an update that has no stamp and answer with a validation error. Leaving the stamp out is therefore a way of switching the safety net off, and it should be a deliberate choice rather than an accident.
Sending the very same request again after a conflict will fail every single time, because it still carries the old stamp. A correct retry reads fresh data first and re-applies the intended change on top of it.
6Inside the API Server
What actually happens between a request arriving and the database accepting or rejecting it.
The API server is a front door with several checkpoints. Every write request walks through the same sequence, and the version check sits at the very end, right next to the database, where it can be made truly reliable.
flowchart TD
A["Request arrives"] --> B["Authentication: who are you?"]
B --> C["Authorization: may you do this?"]
C --> D["Mutating admission: adjust the object"]
D --> E["Schema validation: is it well formed?"]
E --> F["Validating admission: any policy objections?"]
F --> G["Storage layer: read current, run preconditions"]
G --> H{"etcd transaction: is stored version still the expected one?"}
H -->|Yes| I["Write and return new stamp"]
H -->|No| J["Re-read, then retry internally or return 409"]
The early checkpoints
Authentication proves who is calling. Authorization, usually through role-based access control, decides whether that caller is allowed to update this kind of object. Mutating admission may adjust the incoming object, for example by adding default values or injecting extra settings. Validation then checks that the result is well formed, and validating admission applies policy rules. None of these steps look at the version stamp for concurrency purposes, because the world could still change after they finish.
The storage layer and the atomic check
The storage layer reads the currently stored object, sometimes using a recent cached copy as a quick hint, and applies the requested change to it. It then verifies the preconditions that the request carried, such as the expected stamp or expected uid. The final and authoritative step is a single etcd transaction that says, in effect, “if this key’s last-modified revision is still the one I read, store the new value, otherwise tell me what is there now.” Because etcd runs that transaction atomically, two competing writers can never both succeed from the same starting point.
If the transaction fails, what happens next depends on the request. When the client pinned a specific version, the server cannot guess what to do, so it returns a 409 Conflict and lets the client decide. When the client did not pin a version, for example in a patch that only describes a small change, the server can safely re-read the latest object, re-apply the small change and try again by itself. The client never even notices.
No-op writes are skipped
The server also notices when an update would store exactly the same bytes that are already stored. In that case it skips the database write, the stamp does not change, and no watch events are produced. This is a quiet but valuable optimisation. Controllers that re-apply the same desired state again and again cost almost nothing, and watchers are not woken up for nothing.
A conflict can occur after mutating webhooks have already run, because the version check happens last. Webhooks may therefore see the same logical request more than once, so they should behave the same way each time they are called.
7Update, Patch and Apply: Three Ways to Write
Kubernetes offers several write styles, and each one treats the version stamp differently.
Many people assume that every write in Kubernetes works the same way. In reality there are three main styles, and picking the right one can reduce conflicts dramatically. They differ in how much of the object they send and in who is responsible for resolving a collision.
Update (PUT)
Sends the whole object back. It normally carries the stamp, so a collision is reported to the client as a 409.
Patch
Sends only the difference. The server applies it to the latest stored object, so no conflict occurs unless the patch itself includes a stamp.
Server-Side Apply
Sends the fields you care about and claims them. The server tracks ownership per field and reports conflicts only when two owners disagree about a field.
Subresources
Status and scale have their own endpoints so different writers can touch different parts, though they still share one stamp.
| Style | What it sends | Carries stamp? | Conflict when | Best for |
|---|---|---|---|---|
| Update | Whole object | Yes, normally | Stored stamp differs | Read-modify-write of a full object |
| Patch | Only the change | Optional | Only if a stamp is included | Small targeted edits |
| Server-side apply | Fields you own | Not required | Another owner holds the field | Shared objects with many tools |
Patch is gentle, not magic
A patch describes what to change instead of what the whole object should look like, so it naturally avoids most collisions. If you only want to add one label, a patch leaves every other field exactly as the latest stored object has it. There are several patch formats. A JSON patch is a list of precise operations and can even include a test operation that fails if a value is not what you expect. A merge patch overlays your change on top of the object, and a strategic merge patch does the same while understanding how lists of built-in objects should be combined.
A plain merge patch replaces an entire list instead of merging into it. If two tools both patch the same list that way, the second patch can still wipe out the first tool’s entries even though no conflict was reported.
Two different kinds of 409
Server-side apply introduces a second meaning for the same status code. A version conflict says “your copy is old.” A field conflict says “another manager owns this field and you tried to change it to a different value.” The first is solved by reading again. The second is a question of ownership: you can choose to give up the field, agree with the current value, or deliberately force your value and take ownership. Telling these two situations apart in logs saves a lot of confusion.
A full update is like handing in a whole rewritten page and hoping nobody else edited it meanwhile. A patch is like passing a sticky note that says “change this one word.” Server-side apply is like a shared document where each person puts their name next to the paragraphs they are responsible for, and the document only complains if two names claim the same paragraph.
8Preconditions on Delete and Other Operations
The same “only if unchanged” idea protects more than updates.
Version stamps are not limited to saving edits. Delete requests can carry preconditions too, which are conditions that must be true or the delete is refused. Two preconditions are available: the object’s uid and its resourceVersion. They let a caller say “delete this exact thing, and only if it is still the thing I looked at.”
Imagine you leave a note on a parked car that says “tow the blue car in space 12.” If the blue car drove away and a red car parked in the same space, a careless tow truck would remove the wrong car. A careful one checks the licence plate, which is what the uid precondition does for objects.
Why the uid matters
Names are reused. A StatefulSet may delete Pod “web-0” and create a new Pod with the very same name seconds later. A controller that decided to delete the old “web-0” could accidentally delete the healthy new one if it only used the name. Because the uid is different for every incarnation, a delete with a uid precondition fails safely with a conflict when the object has been replaced.
Delete only this incarnation
A controller wants to remove a particular Pod it inspected. It attaches that Pod’s uid. If the Pod was already replaced, the delete is refused and the new Pod survives.
Delete only if unchanged
A cleanup tool decided an object was unused based on what it read. By attaching the stamp, it ensures the delete only happens if nobody has touched the object since, such as by adding a new reference.
Garbage collection
Owner references record the uid of the owner, not just its name. The garbage collector relies on this to avoid removing dependents because of a different object that merely shares a name.
Create as a one-winner race
Creation is protected by name uniqueness. If two clients try to create the same name, one wins and the other receives an AlreadyExists error. Many coordination tricks, such as the first lease holder, are built on this.
When the scheduler assigns a Pod to a Node, the API server refuses to bind a Pod that already has a Node. Two schedulers racing over one Pod cannot both succeed, which is the same principle at work.
9resourceVersion When Reading: Freshness Choices
On reads, the stamp means something different: how fresh does my answer have to be?
So far the stamp has been a guard on writes. When you read or list objects, you can also pass a resourceVersion, but here it acts as a freshness dial. It lets the caller choose between “give me the most accurate answer even if it costs more” and “give me something quick and good enough.”
| What you ask for | What you get | Trade-off |
|---|---|---|
| Stamp left out | The most recent data, confirmed against the source of truth | Most accurate, most expensive |
| Stamp set to zero | Any version the server can answer with, usually from its in-memory cache | Fastest, possibly stale |
| A specific stamp with a “not older than” match rule | Data at least as fresh as that stamp | Balanced |
| A specific stamp with an “exact” match rule | The data exactly as it was at that stamp, if history still exists | Precise, can expire after compaction |
Asking for “any version” is like grabbing whichever newspaper is lying on the table. Leaving the stamp out is like phoning the printing office and asking what today’s front page says right now. “Not older than” is like saying you want a paper from this morning or later, but you do not mind which edition.
Why the cache matters
The API server keeps a watch cache of recent objects in memory. Answering from that cache protects the database from a flood of heavy list requests, which is vital in big clusters. The price is that the cache can lag the database by a small amount. In recent Kubernetes releases the server can also serve consistent reads from its cache after confirming the cache is caught up, which brings together accuracy and speed.
Pagination and the continue token
Large lists can be fetched in pages using a limit and a continue token. The first page fixes a snapshot at a particular stamp, and every later page reads from that same snapshot, so you never see a half-old, half-new list. The snapshot cannot be kept forever, though. If the database compacts away that history before you finish, the next page is refused as expired and you have to start the listing again.
Reading with the “any version” choice and then writing the result back is a perfectly legal pattern, but the stamp you hold may already be behind. Expect conflicts and be ready to retry, or use a patch that does not depend on the stamp.
10Watches, Informers and the 410 Gone Response
How programs follow a stream of changes without polling and without missing anything.
Controllers do not repeatedly ask “what changed?” every second. Instead they open a watch, a long-lived connection through which the server pushes an event whenever an object is added, modified or deleted. A watch takes a starting stamp, and the server sends every change that happened after that point. This is the second great use of resourceVersion.
The list-then-watch pattern
The standard recipe, built into the client libraries as an informer, has two parts. First, list all objects of a kind and remember the stamp attached to the list. Second, start a watch from exactly that stamp. Because the list describes the world at stamp N and the watch begins at N, there is no gap where a change could slip through and no overlap where a change could be delivered twice. The informer keeps a local cache updated from the events, and controllers read from that cache instead of hammering the server.
flowchart TD
A["List all objects"] --> B["Save snapshot and its stamp"]
B --> C["Watch from that stamp"]
C --> D["Apply each event to local cache"]
D --> E{"Connection lost or stamp too old?"}
E -->|Connection lost| F["Reconnect from last stamp seen"]
F --> C
E -->|410 Gone| A
D --> C
List
The informer gets every object plus the list’s stamp.
Watch
It opens a stream starting at that stamp and updates its cache as events arrive.
Disconnect
Networks drop, servers restart, and proxies close idle connections. The informer remembers the stamp of the last event it processed.
Resume or relist
It reconnects from the remembered stamp. If that point is too old, the server answers 410 Gone and the informer starts over with a fresh list.
Why 410 Gone happens
The server only remembers a limited window of recent history. The watch cache holds recent events in memory, and the database periodically compacts old revisions away, by default roughly every five minutes. If a client asks to resume from a stamp older than the remembered window, the server cannot replay the missing events honestly, so it says 410 Gone, meaning “that point in history is no longer available.” The correct response is to relist and rebuild the cache.
Watches can ask for bookmark events. These are small messages that carry no object change, only the latest stamp. They keep a quiet watcher’s resume point fresh, which reduces expensive relists after a reconnect.
Imagine following a group chat. You note the last message you read, and when you come back you scroll from that message onward. If the chat app only keeps the last hour of messages and you were away for a day, you cannot scroll from your spot. You have to reload the whole conversation and start fresh, which is exactly a relist.
11How Controllers Live with Conflicts
For a controller, a conflict is not an emergency. It is a normal signal that says “look again.”
A controller works in a loop: observe the current state, compare it with the desired state, and act to close the gap. To stay fast, it usually reads from its informer cache, which is a copy kept up to date by the watch stream. That copy is almost always right, but it can be a few moments behind. When the controller writes using a stamp taken from a slightly stale copy, the server may answer with a conflict.
This is expected, and the right reaction is calm. Kubernetes controllers are level-triggered, which means they act on the current state of the world rather than on a history of events. A failed write is therefore not a lost instruction. The controller puts the object’s key back on its work queue, waits a short and growing delay, and handles it again. By then the cache has caught up, the stamp is fresh, and the write succeeds. Because the reconcile logic is idempotent, meaning that running it twice gives the same result as running it once, repeating it is harmless.
flowchart TD
A["Reconcile an object"] --> B["Read latest copy from cache"]
B --> C["Decide the needed change"]
C --> D["Send write with the stamp"]
D --> E{"Result?"}
E -->|Success| F["Done, forget this attempt"]
E -->|Conflict| G["Requeue the key with a growing delay"]
G --> A
E -->|Other error| H["Log it and requeue"]
H --> A
Short retry loops
Sometimes a controller prefers to retry immediately instead of requeueing. The client libraries provide a standard helper for this, usually called retry on conflict. It re-reads the live object, runs the caller’s change function on that fresh copy, and tries to write again, backing off between attempts and giving up after a few tries. The crucial detail is the order of operations. What is repeated is the intention, not the old object. Reapplying a stale copy over and over would never succeed.
A waiter adds dessert to a table’s order on a shared tablet. The tablet says the order changed because a colleague just added drinks. A good waiter does not argue with the tablet. They refresh, see the drinks, and add the dessert on top. The goal, “dessert for table five,” stays the same while the page they build on is updated.
If two controllers each insist on a different value for the same field, they will undo each other forever, producing a storm of writes and conflicts. Retrying harder does not fix this. The fix is to give each field a single owner.
Controller work queues add a delay that grows for each failing key, and they forget the delay after a success. This keeps a problem object from consuming all the controller’s attention.
12Leader Election: Optimistic Concurrency as a Lock
A clever use of the version check that lets many copies of a program agree on a single boss.
For high availability, important components such as the scheduler and the controller manager run as several identical copies. But only one copy should actively do the work at a time, otherwise they would make duplicate or contradictory decisions. The copies choose a leader by competing for a small Kubernetes object called a Lease.
A Lease records who currently holds it, how long the holder may keep it, when it was last renewed, and how many times leadership has changed. The leader renews the Lease regularly by updating it. The other copies keep watching. If they see that the Lease has not been renewed for longer than its allowed duration, they assume the leader is gone and try to take over.
Where optimistic concurrency saves the day
Suppose the leader crashes and two standby copies notice at the same time. Both read the Lease at the same stamp, both prepare an update that says “I am the holder now,” and both send it with that stamp. etcd accepts the first and the server answers the second with a conflict. The loser immediately knows it did not win and goes back to waiting. No special lock server, no extra coordination, only the version check doing exactly what it does everywhere else.
sequenceDiagram
participant X as Candidate X
participant Y as Candidate Y
participant S as API Server
X->>S: Read Lease
S-->>X: Expired, version 40
Y->>S: Read Lease
S-->>Y: Expired, version 40
X->>S: Claim Lease, expecting version 40
S-->>X: Accepted, now version 41
Y->>S: Claim Lease, expecting version 40
S-->>Y: 409 Conflict
Note over X: X is the leader and renews regularly
Note over Y: Y keeps waiting and watching
The clock question
You might wonder whether different computers disagreeing about the time could break this. The design is careful about that. Each candidate does not trust the timestamp written by the leader. Instead it notes, using its own clock, how long it has seen the record stay unchanged. Only when that locally measured time exceeds the lease duration does it attempt a takeover. A leader that cannot renew within its deadline steps down on its own rather than carrying on and risking two active leaders.
Leader election limits who does the work, but a slow, paused leader may briefly still believe it is in charge. Well-designed programs stop acting the moment they fail to renew, and rely on the version check to reject any late writes.
13Hot Objects and Write Contention
Conflicts become a real problem when too many writers crowd around too few objects.
The chance of a conflict grows with two things: how many writers touch the same object and how often each one writes. An object that many components update frequently is called a hot object. Hot objects waste effort because losers redo their work, and in extreme cases the retries themselves create more traffic and more conflicts. Kubernetes itself has had to redesign a few of its own features to cool down hot objects.
Node heartbeats
Every node regularly tells the cluster it is alive. When that heartbeat was written into the large Node object, thousands of nodes produced a constant stream of big writes. Kubernetes moved heartbeats into tiny per-node Lease objects, so the heavy Node object is now rewritten only when something meaningful changes.
Service endpoints
An older design kept every address behind a Service in one single object. Each Pod change rewrote the entire list and every watcher received the whole thing again. EndpointSlices split the addresses into small chunks, by default up to one hundred per slice, so a single Pod change touches only one small object.
Replica count tug of war
A deployment pipeline that always writes a fixed replica number will keep resetting the value that an autoscaler just chose. Both sides think they own that field. The cure is to remove the replica field from the pipeline’s declaration so the autoscaler owns it alone.
The shared registry ConfigMap
Teams sometimes use one ConfigMap as a shared notebook where dozens of jobs add entries. It works with five writers and falls apart with five hundred. Giving each writer its own object removes the contention completely.
A single shared notebook for a whole classroom means students constantly wait or overwrite each other. Giving every student their own notebook, and letting the teacher gather them when needed, removes the crowding entirely. Splitting data so each writer has its own object is the same trick.
The one-writer principle
The most reliable way to prevent conflicts is to design so that each object, or at least each field, has exactly one writer. Spec fields are written by users and tools that express intent. Status fields are written by the controller that observes reality. Separate objects are used for separate concerns. When this principle is followed, the remaining conflicts are rare and harmless. When it is ignored, no retry strategy can save the system.
Look for objects whose stamp changes many times per minute, objects that are very large, and objects that many different components write. Those are the first candidates to split up.
14Design Patterns for Conflict-Safe Controllers
A small toolbox of habits that keeps controllers calm under concurrency.
Anyone who builds tools or operators for Kubernetes will meet conflicts sooner or later. The good news is that a handful of well-known patterns handle nearly every situation. Treat the following cards as a checklist when designing a new controller.
Re-read, then re-apply
On a conflict, fetch the latest object and apply your intended change to it. Never resend the stale copy.
Prefer narrow patches
Send only the fields you really change. Smaller writes overlap less and rarely need the stamp at all.
Server-side apply with a named manager
Give each tool a stable, unique manager name so ownership is clear and conflicts point to a specific party.
One writer per field
Divide responsibility so that no two components ever set the same field.
Status through its own door
Write progress to the status subresource, keeping it apart from user intent in the spec.
Idempotent reconcile
Design the loop so that running it again is always safe. Then every retry is free of side effects.
Backoff with jitter
Wait a growing and slightly random time between retries so competing writers do not collide in lockstep.
Leader election for singletons
Use a Lease when only one replica should do a job at a time.
A safe update recipe
Putting the patterns together gives a simple recipe that works for most controllers.
Read the freshest copy you can afford
Use the cache for speed, and read live from the server when accuracy matters more than cost.
Compute the smallest change
Decide exactly which fields must differ, and nothing more.
Write with the narrowest tool
Use a patch or an apply request, and attach the stamp only when you truly need a precondition.
On conflict, back off and repeat
Re-read, re-apply the intention, and wait a little longer each time.
Give up gracefully
After a few attempts, requeue the key and move on. The next loop will start from fresh state.
Controllers can copy the object’s generation into observedGeneration in the status once they finish. Anyone reading the object can then tell whether the status describes the current desired state or an older one.
15Common Mistakes and Anti-patterns
Five habits that look harmless and cause real trouble in production.
Most conflict-related bugs come from a short list of misunderstandings. Studying them in advance is cheaper than discovering them during an outage.
Problem
A tool treats the stamp as a number, checks whether one is “bigger” than another, and uses that to decide which object is newer.
Why It’s Harmful
The stamp is opaque. It may not be numeric in every setup, its ordering is not promised, and numbers from different object kinds are not meant to be compared. The tool may work for years and then fail after an upgrade or against an extension API.
Correct Approach
Only test whether two stamps are equal. For ordering of events, rely on the order in which a single watch delivers them.
Problem
After a conflict, the client retries the identical request in a tight loop.
Why It’s Harmful
The request still carries the outdated stamp, so every attempt fails. The loop wastes capacity and adds load to an already busy API server.
Correct Approach
Re-read the object, apply the intended change to the new copy, and retry with backoff and a limit on attempts.
Problem
To make conflict errors disappear, the developer strips the stamp from updates so the server always accepts them.
Why It’s Harmful
The errors vanish but the underlying collisions remain, and now they erase other writers’ changes silently. This trades a visible, fixable problem for an invisible one.
Correct Approach
Keep the stamp and handle conflicts properly, or switch to patches or server-side apply, which are designed for shared objects.
Problem
A controller holds on to an object it read long ago and keeps writing it back with the original stamp.
Why It’s Harmful
Once anything else changes the object, every write is refused, and the controller may appear stuck. Long-lived copies also hide newer information.
Correct Approach
Treat each reconcile as a fresh start. Read the current state at the beginning, and never keep object copies across loops.
Problem
An application stores the stamp in its own database as if it were a business version number.
Why It’s Harmful
The stamp changes for reasons unrelated to business meaning, such as label edits and status updates, and it can become unusable after history is compacted. It also couples the application to internals it should not know.
Correct Approach
Keep an explicit version or hash of your own data, and use the generation field when you want to track changes to desired state.
Symptom to cause quick guide
| Symptom | Likely cause | Healthy response |
|---|---|---|
| A burst of 409 errors from one controller | Stale cache or two writers on one object | Check ownership, add backoff |
| Controller never succeeds on one object | Retrying a stale copy | Re-read before each attempt |
| Fields keep reverting | Two tools enforcing different values | Assign one owner per field |
| Frequent 410 Gone and relists | Watchers falling behind or very quiet watches | Enable bookmarks, check resources |
| Updates vanish without errors | Unconditional updates or whole-list merge patches | Use stamps or server-side apply |
If a conflict error makes you want to switch the check off, that is usually a sign the design needs fewer writers on that object, not a weaker guard.
16Security, Monitoring and Observability
Version stamps protect integrity, not secrecy, and they leave useful footprints you can measure.
Security thinking
It is important to be clear about what the stamp is not. It is not a password, a signature or an access control. It does not decide who may change an object. That job belongs to authentication and role-based access control. The stamp only answers the narrow question “has this changed since I looked?” Preconditions on delete protect against accidents and races, such as removing a replacement object by mistake, but they do not stop a person who has permission from doing something harmful on purpose. Policies, such as admission rules that make certain fields immutable, are the right tool for that.
Opacity also has a small security benefit. Because clients are told never to interpret the stamp, nobody builds trust decisions on top of its structure, and the platform stays free to change how it is produced.
What to watch
Conflicts leave clear traces in logs, metrics and audit records. A few conflicts per hour in a busy cluster are normal and prove the safety net is working. What deserves attention is a sustained high rate, a rate that keeps climbing, or conflicts on the same object that never resolve.
API server request counts
The request counter at the API server, split by response code, shows how many 409 and 410 responses occur for each verb and resource kind.
Client and queue metrics
Client-side request counters and work queue retry counters show which controller is struggling and how deep its backlog is.
Controller messages
Controllers usually log the phrase that an operation cannot be fulfilled because the object has been modified. A repeating message for one object is a clue.
Audit records
Audit logs record the user, the verb, the object and the response code, so you can see exactly who was colliding with whom.
| Signal | What it tells you | Looks healthy | Looks worrying |
|---|---|---|---|
| 409 responses per resource | Where collisions occur | Low and steady | Rising or concentrated on one object |
| Work queue retries | How often controllers repeat work | Small bursts that settle | Constant retries on the same keys |
| 410 responses on watches | Watchers losing their place | Rare, after restarts | Frequent, causing relists |
| Watch and list latency | Load on the API server | Stable | Spikes after many relists |
| Database size and compaction | History retention pressure | Bounded | Growing without limit |
One conflict means nothing. Ask whether the conflict rate is stable, whether retries eventually succeed, and whether a few objects account for most of the noise.
17Advantages, Disadvantages and Trade-offs
A fair look at what the design buys and what it costs.
No concurrency strategy is perfect, and Kubernetes made a conscious bet. Seeing the whole picture helps you decide when to lean on the built-in behaviour and when to design around it.
Advantages
- Lost updates are detected instead of hidden
- No lock service, no lock timeouts and no deadlocks
- Works the same for people, scripts and controllers
- Scales to very large numbers of mostly independent writers
- Gives watches a precise starting line with no gaps
- Makes leader election possible with ordinary objects
Disadvantages / Trade-offs
- Every client must be written to expect and handle 409 responses
- Hot objects waste work through retries
- The stamp covers the whole object, so unrelated edits can still collide
- Opaque values give limited help for debugging
- Fairness is not guaranteed, and an unlucky writer may retry many times
How it compares with other approaches
| Approach | Idea | Strength | Weakness |
|---|---|---|---|
| Last write wins | Accept every write | Simplest | Silently loses work |
| Pessimistic locking | Lock before editing | Orderly under heavy contention | Stuck locks, slow, complex |
| Optimistic version check | Verify nothing changed at save time | Cheap and crash-safe | Retries under contention |
| Field-level ownership merge | Track who owns each field | Fewer false conflicts | Needs discipline about managers |
Kubernetes actually uses a blend. The coarse version stamp guards whole-object writes, while server-side apply adds field-level ownership on top for shared objects. This combination keeps the cheap, crash-safe core and softens its biggest weakness, which is that unrelated edits to the same object can collide.
18Real-World and Industry Examples
Where these ideas appear in everyday cluster life.
You do not need to build a controller to meet the version stamp. It quietly shapes everyday tools and platform designs, and recognising it makes puzzling behaviour easier to explain.
Editing live objects by hand
When an engineer opens a live object in an editor and saves it, the saved copy carries the stamp from when it was opened. If a controller changed the object while the editor was open, the save is refused with a conflict, and the engineer’s edits are kept in a temporary file so they are not lost. This is optimistic concurrency protecting a human.
GitOps tools beside autoscalers
Platforms that continuously sync manifests from a repository often share objects with autoscalers and operators. Teams that adopt server-side apply with a clear manager name, and leave out fields owned by other tools, avoid endless reverting battles and noisy conflict logs.
Operators for databases and certificates
Operators write progress and health into the status of their custom resources while users edit the spec. Splitting spec and status across separate write paths, and recording the observed generation, keeps these two audiences from stepping on each other.
Scheduler and controller manager high availability
Production control planes run several replicas of core components, and each group elects one active leader through a Lease. The version check ensures exactly one replica wins each election, even when several notice the old leader’s disappearance in the same instant.
Large multi-tenant platforms
Companies that run thousands of nodes and many teams learn quickly that shared objects do not scale. They shard data into per-team or per-node objects, use small heartbeat objects, and monitor conflict rates as an early warning of design problems.
Disaster recovery tools
Backup and restore tools that capture and replay objects must be careful about stamps and uids. A restored object receives a new identity and a new stamp, and tools that assume the old values will still match find their preconditions failing, which is correct and expected behaviour.
Mature teams track the conflict rate the same way they track error rates and latency. A calm graph is proof that ownership boundaries are clean.
19Frequently Asked Questions
Short answers to the questions that beginners ask most often.
No. The stamp changes on every stored change, including status and labels. Generation only increases when the desired state in the spec changes, so it moves far more slowly.
You can only send back a value you received. Sending a different value simply causes a conflict. Leaving it out may allow an unconditional update, but that removes your protection against overwriting other changes.
Usually not. It means someone else changed the object first, and it is the normal way the system keeps writers honest. It becomes a concern only when it is constant or never resolves.
If the new object is identical to what is stored, the server skips the write, so nothing changes and no watch event is sent. The same thing happens when you re-apply an unchanged definition.
The format is opaque and may differ between storage setups. The official guidance allows equality checks only. Event order should be taken from the order of a single watch stream.
Mostly, because the server applies the small change to the latest object. A patch can still conflict if it includes a stamp, and some patch types replace whole lists, which can overwrite another tool’s entries.
The point in history you asked to resume from has been forgotten, usually because it is older than the retained window. The right response is to list everything again and start a new watch from the new list’s stamp.
Reading from a local cache is fast and spares the API server from enormous load. Because controllers are level-triggered and idempotent, the occasional stale read just causes a harmless conflict and a retry.
A version conflict says your copy is outdated. A field conflict says another manager owns a field you tried to set to a different value. The first is fixed by re-reading, the second by deciding who should own the field.
No. It is purely a freshness check. Access control belongs to authentication and authorization, and policy rules belong to admission control.
20Summary and Key Takeaways
Kubernetes is a crowded whiteboard where people and programs constantly read and write the same objects. Instead of making everyone queue behind locks, it stamps every object with a resourceVersion, lets writers work freely, and checks at save time that nobody changed the object in the meantime. A mismatch produces a clear 409 Conflict, and the loser simply reads the latest copy and tries again. The same stamp gives reads a freshness dial, gives watches an exact starting line, powers leader election, and underpins safe deletes through preconditions. Once you see the pattern, a great deal of Kubernetes behaviour, from controller retries to informer relists, becomes easy to explain.
Key Takeaways
- The stamp is a freshness check —
resourceVersionsays which stored edition you saw, and the server refuses writes made from an outdated edition. - It is opaque — treat it as text, copy it back and compare for equality only, and never rely on its numeric order or inner structure.
- Conflicts are normal — a 409 means “someone changed it first.” Respond by re-reading and re-applying your intention with backoff, never by resending a stale copy.
- Choose the right write style — updates carry the stamp, patches send small changes, and server-side apply tracks field ownership. Narrow writes mean fewer collisions.
- Watches depend on it — list, then watch from the list’s stamp. When the server answers 410 Gone, list again.
- Design for one writer — give each field or object a single owner, split hot objects, and use small Lease objects for frequent heartbeats and leader election.
- Measure the rate, not the event — track 409 and 410 responses, retries and audit records. Steady low numbers are healthy, and climbing numbers point to a design problem.