What Zero-Downtime Deployment Actually Requires
Zero-downtime deployment depends on overlapping versions, safe traffic shifts, and database changes that work before and after release.
The deploy finished cleanly. The new containers came up, the load balancer moved traffic, no health check failed, no server went dark, and the graph stayed green. Then the errors started.
An old browser tab called an API field the new backend had removed. A queue worker picked up a message from the previous version and couldn’t parse it. The database had already moved to the new shape, so rolling the application back didn’t put the system back where it was. Nothing was down in the obvious sense.
The problem was version overlap. For a while the system had two versions alive at once, and the application had been written as if only one version could ever exist. That’s the real zero-downtime requirement, and it’s the easy part to miss: every intermediate state has to work.
The new version must coexist with the old version
A deployment has downtime the moment no valid version can serve a request, and that can happen even when the servers never go offline. A new backend expects a column that doesn’t exist yet. An old frontend calls an API field the new backend removed. A queue worker reads a message produced by code from five minutes ago and can’t parse it. A load balancer sends traffic to a container that started but hasn’t finished warming its cache. From the outside the process looks continuous. Inside, the contract broke.
So the first rule is simple: version N and version N+1 have to overlap safely. That means:
- Old clients can call new servers
- New clients can call old servers
- Old workers can process new messages where possible
- New workers can process old messages
- The database supports both versions during the transition
- The rollback path still works after the migration step
This is where a lot of teams discover that deployment and release aren’t the same thing. Deployment puts code somewhere it can run. Release makes that code matter to users. They’re two different operations, and treating them as one is how the errors above happen.
The smallest useful diagram has two versions alive
The generic shape looks like this:
state traffic router sends requests to
before version A
during version A and version B
after version B
The labels change with the stack. In Kubernetes the router might be a Service pointing at pods selected by labels. On a frontend site it might be an edge CDN serving one asset bundle to most users while a new bundle sits behind a preview domain. In a more traditional setup it might be a load balancer with two autoscaling groups behind it. The shape matters more than the product name: two versions exist at the same time, traffic moves across either gradually or all at once, and the old version stays alive long enough to prove the new one can handle the real system.
Blue-green is a traffic switch, not a safety guarantee
Blue-green deployment gets described as the zero-downtime answer. It helps, but only if the application can survive the switch.
phase blue environment green environment
before switch public traffic, version A warm but private, version B
after switch kept for rollback public traffic, version B
That switch can be a DNS change, a load balancer target update, a Kubernetes selector change, or a platform-level promotion. The implementation changes, but the risk stays familiar. What happens to requests already on blue? To background jobs blue has already claimed? To data green writes that blue can’t read if you have to roll back?
Blue-green gives you a clean place to start the new version. It doesn’t make incompatible versions compatible. That’s not a flaw in the pattern, it’s just the boundary of it.
Kubernetes still needs application discipline
Kubernetes can make rolling deployments feel almost automatic.
desired replicas: 4
step | old pods | new pods | total serving
1 | 4 | 0 | 4
2 | 3 | 1 | 4
3 | 2 | 2 | 4
4 | 1 | 3 | 4
5 | 0 | 4 | 4
That’s useful. It gives the platform a way to replace capacity without dropping the whole service. But Kubernetes only knows what the application tells it. Readiness checks have to mean the process can actually serve traffic, not just that the port opened. Shutdown has to stop accepting new work before the process exits. Long requests need time to finish. Queue consumers need to release or finish work cleanly.
A deployment manifest can express the mechanics:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
That tells the platform to keep capacity during the replacement. It doesn’t say the code is backward compatible. It won’t drain a WebSocket for you, and it won’t teach a worker how to abandon a job without duplicating a charge. The platform can coordinate the rollout, but the application still owns the contract.
Frontend deployment has its own version overlap
Frontend systems tend to hide deployment risk, because there’s no server process to restart. Upload new files, invalidate the cache, done. Except users keep old pages open. A browser might run yesterday’s JavaScript while calling today’s API. A CDN might serve an HTML file that points at a new asset while an edge location still has the old one cached. A mobile webview might hold onto a bundle far longer than anyone expects.
The safer flow looks like this:
1. Upload new immutable assets
/assets/app.v1.js still available
/assets/app.v2.js newly available
2. Update the entry point
/index.html references app.v2.js
3. Keep old assets for old tabs
old browser tab keeps using app.v1.js
new browser tab loads app.v2.js
This is why hashed asset filenames work so well. New files don’t overwrite old ones, so old pages keep using old assets while new pages load the new ones. The API needs the same discipline. Dropping a field because the new frontend no longer uses it will still break the old tabs that haven’t refreshed. The browser is part of the deployment window, whether you planned for it or not.
Preview environments solve a human problem
Zero-downtime deployment usually gets discussed as a runtime problem, but teams also need a way to look at changes before they release. That doesn’t require one blessed architecture. It just requires a stable path from a change to an environment you can review.
[pull request]
|
v
[build artifact]
|
v
[preview URL or namespace]
|
v
[team review]
|
v
[merge]
|
v
[production deployment]
For a frontend that might be a preview URL per branch. For a backend, an isolated environment with its own database snapshot or seeded test data. For a Kubernetes system, a namespace per pull request. For a smaller team, a shared staging environment rebuilt from main. The point isn’t the shape, it’s that humans can see the change before production users depend on it.
A good preview environment answers practical questions:
- Does the page render with real routing and real assets?
- Does the API contract match what the caller expects?
- Does the migration run against data that resembles production?
- Does the background worker behave when it sees old and new messages?
- Can product, support, and operations see the change before release?
Local review catches one class of problem, preview review catches another, and production still catches the parts only production can reveal. The job of the deployment system is to make each of those steps cheaper.
Database changes decide whether rollback is real
Most zero-downtime deployment plans break at the database. Application code can roll forward and back in seconds. Database state usually can’t. If version B drops a column that version A still reads, rollback stops being a rollback and becomes a second incident. If version B changes the meaning of a value in place, version A can read that value just fine and still behave incorrectly.
That’s why expand and contract migrations matter. The exact SQL changes from one database to the next, but the shape stays the same. First, expand the schema without breaking the old application:
alter table users
add column display_name text;
Version A keeps reading full_name, version B starts writing both full_name and display_name, and nothing has to switch at the exact moment the column appears.
Then backfill in batches, outside the schema migration:
update users
set display_name = full_name
where display_name is null
and id >= 10000
and id < 20000;
That same query runs over the next range, then the next one. The batch size depends on the table, the indexes, and how much write pressure the database can absorb.
Only once the new field has real data should the application switch its reads:
select coalesce(display_name, full_name) as name
from users
where id = ?;
That read path is what keeps rollback possible. If the new column has a gap, the old value is still sitting there.
The contract step comes later:
alter table users
drop column full_name;
That line belongs after the old application versions are gone, the background jobs understand the new shape, and the team has decided rollback no longer depends on the old column.
All of this takes longer than a single migration, and that’s the trade. The system spends more time in a transitional state so that no single deployment has to carry the whole risk. The same idea applies well beyond columns. Rename an API field by supporting both names for a while. Change a queue message by adding a version field and still accepting the old payload. Move data between services by writing to both until the new path has proven itself. Compatibility isn’t free. Neither is downtime.
Health checks need to reflect real readiness
A process that accepts a TCP connection isn’t necessarily ready. It might still be loading configuration, or waiting on the database, or warming a model, hydrating a cache, running a startup check, or waiting on a dependency with strict rate limits. The readiness signal should tell the router when traffic is safe. The liveness signal should tell the platform when the process is broken enough to restart. Those are two different signals, and it matters that you keep them apart.
If readiness is too shallow, traffic arrives too early. If liveness is too aggressive, the platform kills slow but recoverable processes. If both checks hit the same overloaded dependency, the health check can turn a small incident into a bigger one. Zero downtime depends on boring details like this, not because health checks are interesting, but because routers believe them.
Draining matters as much as starting
Most deployment plans focus on bringing the new version up. The old version also needs a clean way out.
[shutdown signal]
|
v
[readiness turns false]
|
v
[router stops new traffic]
|
v
[active requests finish]
|
v
[jobs are finished or released]
|
v
[process exits]
Without that sequence, the platform can interrupt work that’s still in flight. A user sees a failed request. A webhook gets processed twice. A job updates one table but not another. A payment flow reaches the external provider and loses the local confirmation. The hard part isn’t always serving the next request. Sometimes it’s finishing the last one.
Feature flags reduce risk when they have an exit
Feature flags help separate deployment from release. They let you deploy dormant code, turn it on for internal users, expose it to a small group, and switch it off quickly if it misbehaves. That fits zero-downtime deployment well.
state who sees it
off nobody
internal team accounts
limited selected users or percentage
default on most users
removed no flag, no old path
The last step is the one teams forget. A flag without a removal plan becomes another version overlap that never ends: the codebase keeps both paths, the tests have to cover both paths, and operators have to remember which state production is actually in. Flags earn their keep when they make a risky change reversible for a little while. They get expensive when they quietly turn temporary compatibility into permanent complexity.
The real requirement is sequencing
Zero downtime isn’t a platform feature you buy once. It’s what you get from sequencing changes so that every intermediate state works. The sequence usually looks like this:
1. Make the system accept the future shape
2. Deploy code that can work with both shapes
3. Shift traffic gradually or switch environments
4. Watch the signals that prove the new path works
5. Remove the old path only after it is unused
That can run on Kubernetes, a CDN, a serverless platform, a VM fleet, or a pair of load-balanced servers. The tools change the ergonomics. They don’t remove the need for overlap. So the useful question isn’t “does this platform support zero-downtime deployment?” It’s “which versions of this system have to exist at the same time, and have we made that safe?” That’s where the deployment plan actually starts.