Web Development Bug Report

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.

John Nguyen John Nguyen @devbugs · March 11, 2026 · 9 min read · 63 Views
Banner: several save actions where only the latest survives and the earlier ones are cancelled

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

  1. Edit the price of row 101, press Tab.
  2. Within about 300 ms edit row 102, press Tab.
  3. 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. saveRequested increments it, saveSucceeded/saveFailed/saveSuperseded decrement 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.
  • actionChannel plus 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.
  • debounce from 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 as takeLatest.

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-syntax rule flags takeLatest used with any action type ending in Requested that 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 takeLatest only for queries whose older results are worthless, never for commands.
  • Scope concurrency by entity key when commands should collapse per entity.
  • Always add a finally with yield 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

Add a label

#react #javascript #concurrency #redux-saga #redux
Written by
John Nguyen @devbugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.