With a mature continuous delivery pipeline, rolling back a bad release is usually straightforward.
We can simply redeploy a previous build. Even better, with feature flags, we can often disable a problematic feature remotely and fix it at our own pace.
But there is a trap that is easy to overlook, especially in frontend applications.
Rolling back your JavaScript bundle does not roll back the data that already lives in your users’ browsers.
In particular, I’m talking about localStorage.
We often use it to persist user preferences—dark mode, filter settings, selected tabs, recently viewed items, and many other pieces of UI state. It feels harmless because it’s just a small browser API.
But in reality, localStorage behaves much more like a distributed database.
Once a value is written, it lives independently of your deployment pipeline. Your new version may have to read data written by an old version, and after an emergency rollback, your old version may suddenly have to deal with data written by the new one.
Imagine you deploy a feature that changes the format of some persisted data.
The release goes badly, so you immediately roll back.
Your CDN is serving the stable bundle again, your monitoring dashboards look healthy, and the incident appears to be over.
Except it isn’t.
The failed release may already have written a new data format into thousands of browsers. The old code comes back online, tries to read that data, and crashes.
You fixed one production incident, only to create another.
This is a compatibility problem that many frontend teams never think about until they encounter it in production.
In this issue, we’ll take a deep dive into how this happens, look at a few common scenarios, and explore several patterns that make browser storage safe across deployments and rollbacks.
I’ve also made a YouTube video covering the same topic. If you prefer a live walkthrough with code and a working example, feel free to check that out as well.
In this issue, I’d like to go a bit deeper into the underlying design problem and the patterns that can help avoid it.
To make this more concrete, let’s look at a very common feature.
Imagine a board application with a row of assignee avatars that act as filters.
When users select a few people, we want that preference to survive a page refresh, so we save it in localStorage.
The first implementation is about as simple as it gets.
For board 1, we use the following key:
board-assignee-filter:1The selected assignees are stored as a comma-separated string:
2,3And restoring the state is equally simple:
const raw = localStorage.getItem("board-assignee-filter:1");
const assigneeIds = raw ? raw.split(",").map(Number) : [];Everything works as expected.
The user refreshes the page, and the selected filters come back automatically.
At first glance, this looks like an ordinary frontend feature.
But there is an important detail.
The moment that value is written, it leaves your deployment pipeline and becomes data that lives on a user’s device.
You no longer control it.
A few weeks later, the product requirements evolve.
Instead of simply selecting assignees, users should be able to choose whether the filter matches any selected user or all of them.
A plain string is no longer enough.
We decide to store a structured object instead:
{
"assigneeIds": [2, 3],
"matchMode": "any"
}The new implementation becomes:
const raw = localStorage.getItem("board-assignee-filter:1");
const filter = raw
? JSON.parse(raw)
: {
assigneeIds: [],
matchMode: "any",
};If you test this with a clean browser profile, everything works perfectly.
But production users rarely start from a clean state.
Many of them still have the old CSV value:
2,3The first time they load the new version, the application executes:
JSON.parse("2,3");And it crashes.
This is the first compatibility problem.
The new code cannot understand data written by the old code.
Now let’s look at the opposite direction.
Imagine Version 2 goes live.
Some users open the application and save the new JSON format into their browsers.
Then your team discovers a serious bug.
The safest option is to roll back.
Your deployment pipeline does its job.
The old JavaScript bundle is restored.
The CDN is healthy.
Everything looks green.
Except those users still have the new JSON object in localStorage.
When the old application starts, it expects a comma-separated string.
Instead, it receives something like this:
{
"assigneeIds": [2, 3],
"matchMode": "any"
}The old parsing logic tries to process it as CSV.
Best case, the filters become corrupted.
Worst case, the application crashes again.
The rollback was successful.
The client state was not.
This is the trap.
Deployments only revert your application code.
They never revert the data that already exists in your users’ browsers.
Backend engineers usually think carefully about database migrations.
Frontend engineers perform browser storage migrations all the time.
We just rarely call them that.
Once data survives longer than a single deployment, schema evolution becomes an architectural concern.
Whether you are using localStorage, sessionStorage, IndexedDB, or a persisted React Query cache, you should ask two questions.
Can the new version understand data written by the old version?
And just as importantly:
If we have to roll back, can the old version survive data written by the new version?
Thinking about both directions is what keeps a bad release from turning into a much larger incident.
Fortunately, there are a few simple patterns that make browser storage much safer.
The browser is an untrusted environment.
Data may be old, partially written, corrupted, or manually edited.
Reading from browser storage should always be defensive.
Wrap parsing in try...catch.
Detect legacy formats.
Log unexpected values.
And always return a safe fallback state.
The goal is simple:
No matter what lives inside localStorage, your application should never crash because of it.
Instead of storing raw values, include a version number.
{
"version": 2,
"value": {
"assigneeIds": [2, 3],
"matchMode": "any"
}
}When reading, inspect the version and migrate older formats if necessary.
If the data is invalid, fall back to a safe default instead of crashing the application.
Sometimes the format changes so much that sharing the same storage key becomes risky.
Instead of:
board-assignee-filter:1use:
board-assignee-filter-v1:1
board-assignee-filter-v2:1The old application ignores the new key.
The new application can migrate data once and then continue using the new format.
This keeps rollback paths clean.
The next time you change the structure of data stored in the browser, don’t just ask:
Does the new code work?
Also ask:
Can the new code read old data?
Can the old code survive new data if we need to roll back?
Frontend deployments only revert your JavaScript bundle.
They never revert the state that already lives in your users’ browsers.
And once you start thinking about browser storage as a distributed database, compatibility stops being an implementation detail and becomes part of your system design.
No posts

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.