Redux-Saga takeLatest silently dropped saves: cancelled tasks on concurrent edits
Users editing several rows quickly lost some of their changes with no error at all. Our autosave saga used takeLatest, which cancelled the save for row A as soon as row B fired, leaving the UI saying Saved while state rolled back. Per-entity concurrency fixed it.
Our pricing team edits product rows in a spreadsheet-style grid. Each cell commit dispatches rows/saveRequested, and a saga sends a PATCH to the API. After a release that moved autosave from a component-level useEffect into Redux-Saga, users reported that some edits "came back" after a page refresh. There were no errors in the console, no failed requests in Sentry, and the row showed the green "Saved" badge.
The API access logs had the answer hiding in plain sight: for the lost edits there was either no request at all, or a request that the browser had sent and the server had applied, followed by the UI later overwriting it with stale data.
Reproducing it
- Edit the price of row 101, press Tab.
- Within about 300 ms edit row 102, press Tab.
- Refresh. Row 101 shows the old price about 70 percent of the time on a fast connection, and nearly always on a throttled "Slow 4G" profile.
The Redux DevTools log for one occurrence:
rows/saveRequested { id: 101, price: 19.9 }
rows/saveRequested { id: 102, price: 7.5 }
rows/saveSucceeded { id: 102, version: 14 }
There is no saveSucceeded or saveFailed for row 101.
What we ruled out
- Server-side race. The two rows are independent records with separate versions; the API logs showed no 409 or 5xx responses.
- Debounce in the grid. The grid emits one action per cell commit. The two actions in the log confirm both edits reached the store.
- Reducer bug. We replayed the two actions through the reducer in a unit test; the optimistic state was correct after both.
Root cause
The root saga watched saves with takeLatest:
export function* rowsSaga() {
yield takeLatest("rows/saveRequested", saveRow);
}
takeLatest cancels any running saveRow task when a new matching action arrives, regardless of which row it is for. When row 102 was saved while row 101's task was still waiting on call(api.patchRow, ...), the row 101 task was cancelled. Cancellation in redux-saga does not abort the underlying fetch by default, so sometimes the request still reached the server; but the saga never dispatched saveSucceeded, and a later "refresh dirty rows" effect re-fetched row 101 from a cache that predated the save. When the cancel happened before the request was sent, the edit never left the browser at all.
What is happening under the hood
takeLatest(pattern, worker) is a helper built on fork and cancel. Conceptually:
function* takeLatestLike(pattern, worker) {
let last;
while (true) {
const action = yield take(pattern);
if (last) yield cancel(last);
last = yield fork(worker, action);
}
}
When a task is cancelled, redux-saga stops the generator at its current yield and jumps straight to the finally block. Code after the yield call(...) never runs, and neither does a catch: cancellation is not an error. If the worker has no finally that checks yield cancelled(), there is no trace that anything happened.
takeLatest is the right tool for "only the newest result matters" flows, such as search-as-you-type or loading the currently selected tab. It is the wrong tool for commands that must each take effect, such as saves, payments or deletes. The pattern matched the action type, not the entity, so two independent commands were treated as one stream.
Our original worker:
function* saveRow({ payload }) {
try {
const res = yield call(api.patchRow, payload.id, payload.changes);
yield put({ type: "rows/saveSucceeded", payload: res });
} catch (err) {
yield put({ type: "rows/saveFailed", payload: { id: payload.id, err } });
}
}
The "Saved" badge was derived from "no pending error for this row", so a cancelled save looked identical to a successful one.
Environment
| Component | Version / value |
|---|---|
| redux-saga | 1.3.0 |
| Redux Toolkit | 2.5 with configureStore and saga middleware |
| React | 19.0 |
| HTTP client | fetch wrapped in api.patchRow |
| Browsers | Chrome 145, Edge 145 |
| Network | Reproduced on office Wi-Fi and with DevTools "Slow 4G" |
Minimal reproduction
import { runSaga, stdChannel } from "redux-saga";
import { takeLatest, call, put } from "redux-saga/effects";
const dispatched = [];
const api = { patchRow: (id) => new Promise((r) => setTimeout(() => r({ id }), 100)) };
function* saveRow({ payload }) {
const res = yield call(api.patchRow, payload.id);
yield put({ type: "saved", id: res.id });
}
const chan = stdChannel();
runSaga(
{ channel: chan, dispatch: (a) => dispatched.push(a) },
function* () { yield takeLatest("save", saveRow); }
);
chan.put({ type: "save", payload: { id: 101 } });
chan.put({ type: "save", payload: { id: 102 } });
setTimeout(() => console.log(dispatched), 300);
// [ { type: 'saved', id: 102 } ] row 101 never reported
Who is affected
Any redux-saga code that uses takeLatest (or a hand-rolled fork-and-cancel) for actions that represent independent commands on different entities. The more concurrent the UI (grids, kanban boards, bulk edit), the higher the loss rate.
The fix
We wanted two properties: every row's save must complete, and rapid edits to the same row should still collapse to the newest value. That is "takeLatest per row id". redux-saga does not ship it, but it is a few lines with fork and a map of tasks:
import { take, fork, cancel, call, put, cancelled } from "redux-saga/effects";
function takeLatestPerKey(pattern, keyOf, worker) {
return fork(function* () {
const tasks = new Map();
while (true) {
const action = yield take(pattern);
const key = keyOf(action);
const running = tasks.get(key);
if (running && running.isRunning()) yield cancel(running);
const task = yield fork(worker, action);
tasks.set(key, task);
}
});
}
function* saveRow({ payload }) {
const controller = new AbortController();
try {
const res = yield call(api.patchRow, payload.id, payload.changes, controller.signal);
yield put({ type: "rows/saveSucceeded", payload: res });
} catch (err) {
yield put({ type: "rows/saveFailed", payload: { id: payload.id, err } });
} finally {
if (yield cancelled()) {
controller.abort(); // superseded by a newer save for the same row
yield put({ type: "rows/saveSuperseded", payload: { id: payload.id } });
}
}
}
export function* rowsSaga() {
yield takeLatestPerKey("rows/saveRequested", (a) => a.payload.id, saveRow);
}
Two more changes made it safe:
- Same-row edits send the full row state, not a delta. If the cancelled request had already reached the server, the newer request overwrites it completely, so the outcome does not depend on which arrived.
- The badge is driven by an explicit pending counter.
saveRequestedincrements it,saveSucceeded/saveFailed/saveSupersededdecrement it, and "Saved" only shows at zero with no error.
We clean up finished entries in tasks with a periodic sweep in our real code to avoid an ever-growing map for very long sessions; it is omitted above.
Alternatives and trade-offs
takeEvery. Every save runs, in parallel. Simple and correct for different rows, but two quick edits to the same row may land out of order. Acceptable if the API rejects stale writes with a version check.actionChannelplus a sequential loop. Buffers actions and processes one at a time, guaranteeing order across all rows. Correct, but one slow request delays every other row.takeLeading. Ignores new actions while one is running, which would lose saves in the opposite direction. Not suitable.debouncefrom redux-saga/effects. Useful in front of a per-key worker for keystroke-level autosave, but on its own it has the same cross-entity problem astakeLatest.
How to test the fix
A saga unit test with runSaga and a fake API that resolves after a delay:
// rowsSaga.test.js
import { runSaga, stdChannel } from "redux-saga";
import { test, expect, vi } from "vitest";
import { rowsSaga } from "./rowsSaga";
import { api } from "./api";
test("concurrent saves for different rows all succeed", async () => {
vi.spyOn(api, "patchRow").mockImplementation(
(id) => new Promise((r) => setTimeout(() => r({ id }), 50))
);
const out = [];
const chan = stdChannel();
runSaga({ channel: chan, dispatch: (a) => out.push(a) }, rowsSaga);
chan.put({ type: "rows/saveRequested", payload: { id: 101, changes: {} } });
chan.put({ type: "rows/saveRequested", payload: { id: 102, changes: {} } });
chan.put({ type: "rows/saveRequested", payload: { id: 102, changes: {} } });
await new Promise((r) => setTimeout(r, 120));
const ok = out.filter((a) => a.type === "rows/saveSucceeded").map((a) => a.payload.id);
expect(ok.sort()).toEqual([101, 102]);
expect(out.filter((a) => a.type === "rows/saveSuperseded")).toHaveLength(1);
});
npx vitest run src/features/rows/rowsSaga.test.js
Confirming in production
We logged saveRequested, saveSucceeded, saveFailed and saveSuperseded counts to our analytics pipeline. For the old build, requested minus (succeeded + failed) was 6.8 percent over a week of grid usage. After the fix, requested equals succeeded + failed + superseded to within 0.1 percent, the remainder being tabs closed mid-request. Support tickets about "edits coming back" stopped within the first week.
Regression guard
- The unit test above runs in CI.
- An ESLint
no-restricted-syntaxrule flagstakeLatestused with any action type ending inRequestedthat maps to a mutating API call; reviewers must justify exceptions. - A Playwright test edits two rows 100 ms apart with network throttling and asserts both values after reload.
Security implications
None beyond data integrity. One subtle point: aborting a request on the client does not undo it on the server. Any mutation endpoint that can be cancelled mid-flight must be safe to apply and then be overwritten, which is why we send full row state and keep the server's version check. No authorization or exposure changes were involved.
Prevention checklist
- Use
takeLatestonly for queries whose older results are worthless, never for commands. - Scope concurrency by entity key when commands should collapse per entity.
- Always add a
finallywithyield cancelled()to workers that touch the network. - Derive "saved" UI from explicit completion actions, not the absence of errors.
- Send idempotent, full-state updates for anything that may be superseded.
References
- redux-saga docs, Concurrency: https://redux-saga.js.org/docs/advanced/Concurrency
- redux-saga docs, Task cancellation: https://redux-saga.js.org/docs/advanced/TaskCancellation
- redux-saga API reference (takeLatest, takeEvery, actionChannel, runSaga): https://redux-saga.js.org/docs/api
- redux-saga on GitHub: https://github.com/redux-saga/redux-saga
Add a label
Embed this post
<iframe src="https://www.hibug.me/redux-saga-takelatest-cancels-save" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Related Stories
React 19 useFormStatus pending is always false: the hook must live inside the form
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.