Promise.all Concurrent Writes Lose to last-writer-wins
This article may contain affiliate links. Its content is not affected by advertising.
In short
Two concurrent replace-save writes to the same (class, date, section) row inside a single Promise.all resolve to last-writer-wins: whichever commits later overwrites whatever the other just wrote.
The short version
When an AI editing turn returns multiple days and, against spec, puts today (the base date) in both the single-day top-level write and the multi-day days array, two replace-save server actions targeting the same (class, date, section) row run concurrently inside Promise.all, and whichever commits later overwrites the earlier one with last-writer-wins. The fix is to decide up front which sections the top-level write will actually cover, then have the days loop skip writing the same section for the same date.
The state it could become
The component in question is EditorChat’s onApply, which applies an AI chat edit to a class/grade board. A single apply bundles both today’s single-day write (d) and any multi-day additions (days) into one ops array, then saves them together with Promise.all(ops).
Before the fix, the single-day write and the days loop were built completely independently:
const ops: (ReturnType<typeof setScheduleAction> | null)[] = [
willWriteSection("schedules", d, board, allowed, additiveCurrentDay)
? setScheduleAction(scope, targetId, date, d.schedules)
: null,
willWriteSection("notices", d, board, allowed, additiveCurrentDay)
? setNoticesAction(
scope,
targetId,
date,
preservePinnedNotices(pinnedNotices, date, d.notices),
)
: null,
willWriteSection("assignments", d, board, allowed, additiveCurrentDay)
? setAssignmentsAction(scope, targetId, date, d.assignments)
: null,
];
// Multi-day (days): write only each day's non-empty sections, replacing that date.
for (const day of days) {
if (day.schedules.length > 0) {
ops.push(setScheduleAction(scope, targetId, day.date, day.schedules));
}
if (day.notices.length > 0) {
ops.push(
setNoticesAction(
scope,
targetId,
day.date,
preservePinnedNotices(pinnedNotices, day.date, day.notices),
),
);
}
if (day.assignments.length > 0) {
ops.push(setAssignmentsAction(scope, targetId, day.date, day.assignments));
}
}
const results = await Promise.all(ops);
By spec, a multi-day edit’s days array is not supposed to include today’s date (the base date); today is meant to be handled solely by the top-level ops entries above. But the AI’s output does not always honor that, and it can include an entry for today inside days as well. When that happens, a section such as notices gets two separate server actions with the exact same target — the top-level setNoticesAction(scope, targetId, date, ...) and the days-loop setNoticesAction(scope, targetId, day.date, ...) where day.date === date — pushed into the same ops array.
Why
Those two actions fire concurrently inside Promise.all(ops). Both are replace-save writes — upserts that overwrite the target (class, date, section) row wholesale rather than reading and merging — and Promise.all guarantees neither the order in which the promises run nor the order in which they resolve. Whichever of the two commits to the database later overwrites the row entirely with its own content. The one that committed earlier disappears without a trace.
Nothing about this raises an application error or a database constraint violation. Each action succeeds on its own terms, and the results.some((r) => r !== null && !r.ok) failure check does not catch it either. The only way to notice is to reopen the board and find that one section’s content does not match what was intended.
Fixing it
The fix computes, once and up front, which sections the top-level write will actually cover, then has the days loop explicitly skip any entry that is both today’s date and a section the top-level write already handles.
const topLevelWrites = {
schedules: willWriteSection("schedules", d, board, allowed, additiveCurrentDay),
notices: willWriteSection("notices", d, board, allowed, additiveCurrentDay),
assignments: willWriteSection("assignments", d, board, allowed, additiveCurrentDay),
};
const ops: (ReturnType<typeof setScheduleAction> | null)[] = [
topLevelWrites.schedules ? setScheduleAction(scope, targetId, date, d.schedules) : null,
// notices / assignments follow the same topLevelWrites.* pattern
];
for (const day of days) {
const isCurrentDate = day.date === date;
if (day.schedules.length > 0 && !(isCurrentDate && topLevelWrites.schedules)) {
ops.push(setScheduleAction(scope, targetId, day.date, day.schedules));
}
if (day.notices.length > 0 && !(isCurrentDate && topLevelWrites.notices)) {
ops.push(/* ... */);
}
if (day.assignments.length > 0 && !(isCurrentDate && topLevelWrites.assignments)) {
ops.push(setAssignmentsAction(scope, targetId, day.date, day.assignments));
}
}
When the top-level write is empty for a section (the spec-compliant case where a multi-day turn correctly leaves today’s data only in days), topLevelWrites.* is false, the guard does not trigger, and days writes it exactly as before. Data only goes missing when both the top-level write and a days entry target the same section for the same date — and that is the one case this condition isolates.
The invariant — never queue two writes to the same (class, date, section) row inside one Promise.all — is now enforced in a single place, at the point where the array is built.
Preventing a repeat
The same commit adds a regression test for a different bug fixed alongside this one (a feed’s duplicate URLs causing an ON CONFLICT failure), but there is no evidence in the source that this top-level/days duplicate-write guard itself received a test.
Part of why this was hard to catch is that calling a single action (say, setNoticesAction) in isolation never reproduces it. The bug only appears when the AI’s output violates the spec (including today inside days) and the code assembling the ops array fails to notice, ending up with two promises aimed at the same destination. Each individual server action behaves correctly; what was broken was the logic that assembles them into one array.
Frequently asked questions
Q1Did the board actually get corrupted in production?
The commit message reports no production incident. This fix shipped as the third of three fixes in a commit titled a 'bug-hunting sweep'; there is no record of the board actually breaking in the field.
Q2Why does this double write happen at all?
Today's date is not supposed to appear inside the days array — top-level handles today separately. But the AI sometimes violates that and includes today in days too, so a section like notices gets two replace-save actions targeting the same (scope, targetId, date).
Q3Why does the later write erase the earlier one?
Actions like setScheduleAction upsert the whole target row with the given content, not a merge. Once the second upsert commits, the first is gone with no trace, and Promise.all gives no guarantee about which of the two resolves last.
Q4Was this fix covered by a test?
The same commit contains a regression test for a different fix (feed URL de-duplication), but there is no evidence in the source that this top-level/days duplicate-write guard itself got a test.
Environment verified
- Next.js ^16.0.0 / React ^19.0.0
- Merged 2026-07-13 (#1295, third of a 'bug-hunting sweep' of three; no reported production incident)
What this article is based on
- TypeScript file lines 374-411commit 0da560c
- TypeScript file lines 375-421commit 97c43ca
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.