A user is eight steps into a checkout form. They hit Ctrl+R by reflex, or a deploy restarts the tab, and every field is gone. In a single-page app you reach for localStorage and move on. In a Next.js app sitting behind Docker Compose, with several services sharing one Redis for sessions, that reflex does not hold: localStorage does not sync across tabs in real time, sessionStorage dies the moment a new tab opens, and neither one knows anything about a session that lives on the server.
This is the design I landed on for keeping multi-step form state alive across refreshes and tabs, and, more usefully, the trade-offs it cost. The happy path was an afternoon. The failure modes were the actual work.
The shape of the design
Three moving parts, each with one job:
- Redis is the source of truth. Form state is written server-side, keyed to the session, so any tab or any service reads the same thing.
- Pub/Sub is the fast path. When one tab saves, the others hear about it in near real time instead of polling.
- Local storage is the floor. If Redis is unreachable, the app degrades to "no cross-tab sync" instead of "lost data".
Tab A ──save──┐ ┌──push──► Tab B
▼ │
┌────────────────────────────────────┐
│ Next.js API ──► Redis │
│ • versioned form state (TTL) │
│ • Pub/Sub fan-out to open tabs │
└────────────────────────────────────┘
│ (Redis down)
▼
localStorage / IndexedDB (degraded, per-tab)
One clarification that the diagrams usually skip: a browser cannot hold a Redis connection. The tab talks to a Next.js route; that route holds the Redis subscriber and streams events back to each open tab over Server-Sent Events. "Redis Pub/Sub in the browser" is really "Redis Pub/Sub on the server, pushed to the browser over SSE."
State shape: keys that cannot collide
The first thing to get right is the key. In a multi-service, multi-user system, a sloppy key is a data leak waiting to happen. Everything that identifies the owner goes into it:
const dataKey = ({ env, userId, sessionId, formId }) =>
`${env}:form:${userId}:${sessionId}:${formId}`;
// e.g. "prod:form:user123:sess456:checkout"
The payload carries a version and enough metadata to reason about conflicts later, and it always has a TTL so abandoned forms clean themselves up:
const state = {
data, // the form fields
version, // monotonic, for conflict detection
meta: { tabId, updatedAt: Date.now() },
};
await redis.set(dataKey(ctx), JSON.stringify(state), "EX", 86400); // 24h
Separating concerns matters here: sessions and form state both live in Redis but they are not the same thing. Different prefixes (session:* vs form:*), different TTLs (minutes for sessions, hours for a draft), different access patterns. Mixing them is how a session eviction quietly wipes a user's draft.
Saving without hammering Redis
Saving on every keystroke is a self-inflicted denial-of-service on your own Redis. Saving on a timer is stale. The answer is a debounce with a hard ceiling: wait for the typing to pause, but never let more than a few seconds of work go unsaved.
import { debounce } from "lodash";
const persist = debounce(
(data) => saveToRedis(ctx, data),
1000, // settle 1s after the last change
{ leading: false, trailing: true, maxWait: 5000 } // but force a save every 5s
);
const onChange = (data) => {
setFormData(data);
persist(data);
};
maxWait is the part that matters. Without it, a user who types continuously for a minute never triggers the trailing save, and a crash costs them the whole minute. With it, the worst case is five seconds of lost input, which is the trade-off I was willing to make against Redis write pressure.
Cross-tab sync, and where Pub/Sub lies to you
The live update is a publish on save and a subscribe on the channel for the session:
const channel = `form:${sessionId}:updates`;
// server-side: on save, fan out to other tabs
await redis.publish(channel, JSON.stringify(state));
// per tab (via the SSE bridge): apply an incoming update
onServerEvent(channel, (state) => applyRemoteState(state));
This is where a naive implementation quietly breaks. Redis Pub/Sub is fire-and-forget. A tab that is backgrounded, asleep, or briefly disconnected does not queue missed messages; it just misses them. So the message is the fast path, not the truth. The rule I settled on: treat a Pub/Sub message as a hint, and re-read the key from Redis whenever a tab regains focus.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
reloadFromRedis(ctx); // the key is the source of truth; Pub/Sub is just faster
}
});
If you genuinely cannot tolerate a missed update, Pub/Sub is the wrong primitive and Redis Streams is the right one: messages persist, keep their order, and can be replayed with consumer groups. For form drafts, re-reading on focus was cheaper and enough. Naming the limitation and choosing to live with it is the decision; pretending Pub/Sub is reliable delivery is the bug.
The race condition (the part that was actually hard)
Two tabs save within the same instant. My first version looked reasonable and was wrong:
// RACY: read and write are two separate round-trips
const current = JSON.parse(await redis.get(key));
await redis.set(key, JSON.stringify({ ...newData, version: current.version + 1 }));
There is a window between the get and the set where the other tab writes. Both read version 5, both write version 6, and one tab's work is silently gone. Wrapping it in MULTI does not fix it either: MULTI batches commands, it does not give you a lock. Read-then-write across a network is a time-of-check-to-time-of-use bug no matter how you queue it.
The fix is optimistic concurrency: only write if the version has not moved since you read it, and make the check-and-write atomic. A small Lua script does exactly that in one round-trip, on the server, where nothing can interleave:
-- cas.lua: write only if the stored version matches the expected one
local raw = redis.call("GET", KEYS[1])
if raw then
local stored = cjson.decode(raw)
if stored.version ~= tonumber(ARGV[2]) then
return 0 -- someone else won; caller must re-read
end
end
redis.call("SET", KEYS[1], ARGV[1], "EX", 86400)
return 1
const ok = await redis.eval(casScript, 1, key, JSON.stringify(next), expectedVersion);
if (ok === 0) {
const fresh = await reloadFromRedis(ctx); // lost the race, reconcile against truth
resolveConflict(fresh, next);
}
Now a loser knows it lost and can reconcile instead of clobbering. That is the whole difference between "last write wins by accident" and "last write wins on purpose".
When Redis is not there
In a distributed system, the network fails; the only question is whether your code treats that as an event or a crash. Two layers handle it.
Connection retry with exponential backoff, so a blip does not take the app down:
const redis = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
maxRetriesPerRequest: 3,
retryStrategy: (attempt) => Math.min(attempt * 50, 2000),
connectionName: `form-state-${process.env.SERVICE_NAME}`,
});
And graceful degradation, so a full Redis outage costs cross-tab sync, not the user's data:
async function save(ctx, data) {
try {
await saveToRedis(ctx, data);
} catch (e) {
// Redis down: fall back to the browser. No cross-tab, but nothing lost.
localStorage.setItem(localKey(ctx), JSON.stringify(data));
logDegraded("redis_save_failed", e);
}
}
localStorage holds small drafts fine; for larger payloads IndexedDB is the better local store. Either way the contract is the same: the app never hard-fails on a save. When Redis comes back, the next successful write reconciles the local copy against the server.
Cleaning up after itself
Every key has a TTL, so an abandoned draft evaporates on its own. On a successful submit, the draft is deleted immediately rather than waiting out the clock, and the channel is dropped:
async function onSubmit(ctx, data) {
await submitForm(data);
await redis.del(dataKey(ctx));
await redis.unsubscribe(`form:${ctx.sessionId}:updates`);
}
TTL is the safety net for the cases your cleanup code forgets. It is the difference between a Redis instance that stays flat and one that grows until it pages you at 3 a.m.
What it bought, and what it did not
Honestly stated, because a résumé-grade claim that cannot be defended is worse than none:
- Refreshing the page or opening a second tab no longer loses in-progress form state. That was the whole point, and it holds.
- Cross-tab updates land in well under a second on a healthy connection, because Pub/Sub pushes instead of the tabs polling.
- A Redis outage degrades to per-tab local storage instead of a crash or data loss.
What it is not: this is form-state resilience, not real-time collaboration. It is last-writer-wins with conflict detection, not conflict resolution, so two people editing the same field still need a human decision or a merge strategy. Pub/Sub can drop messages to offline tabs, which is why the key, not the message, is the source of truth. And a true offline-first experience would need Service Workers and a CRDT, which is a different and much larger project.
The takeaway
The pattern generalizes past forms: a fast path for latency, a source of truth for correctness, and a graceful floor for when the fast path is gone. The interesting engineering was never the auto-save. It was the honest handling of the three or four ways a distributed system quietly loses your data, and choosing, on purpose, which failures to design out and which to live with.
