Incident Post-Mortem: Settings Race Condition
Incident Metadata
Date: 2026-01-01
Type: Race Condition / Stale State
Severity: High (Data Loss/Reset)
Status: Resolved
1. Summary
When a user saved their Nutrition Targets in the SettingsModal, the application fired two simultaneous mutations to the backend. The second mutation carried default/empty values (0), overwriting the user's intended changes immediately.
2. Root Cause
The SettingsModal triggered two separate state updates on "Save":
const handleSave = () => {
setTargets(localTargets); // Mutation A
setDisplayUnit(localUnit); // Mutation B
};
These setters were wrappers around mutation.mutate() in the custom hook. Crucially, they both read from the current rendered state (targets) to merge updates:
// useSettings.js
const setDisplayUnit = (unit) => {
mutation.mutate({
...targets,
displayUnit: unit
}); // Reads 'targets' from closure
};
The Race:
setTargetsfires. It uses the current state (e.g.,calories: 0->calories: 1500).setDisplayUnitfires synchronously after. It also uses the current state (which is stillcalories: 0because React hasn't re-rendered yet).- The second mutation overwrites the first with the stale
calories: 0.
3. Resolution
We refactored the data access layer to expose a Unified Setter:
// useSettings.js
const updateSettings = (updates) => {
mutation.mutate({
...targets,
...updates // One object, one mutation
});
};
And updated the UI to call this once:
4. Prevention & Learnings
1. Avoid Multiple Mutations for Single Actions
Rule: If a user action (e.g., clicking "Save") updates related data, send a single coherent mutation request. Do not daisy-chain multiple atomic setters.
2. React Query Mutation Keys
Action: Always verify mutationKey usage to help debug overlapping requests in DevTools.
3. Stale Closures Awareness
Rule: Be wary of multiple function calls inside a handler that read from the same React state variable. If Function A updates state, Function B (called immediately after) will see the old state until the next render.
4. Documentation
We have updated the Form Handling Best Practices to emphasize the "Buffered State" pattern, which naturally leads to a single "Save" event.
5. Automated Testing Strategy
Gap: This bug was caught manually. It should have been caught by CI.
Action: Implement Network-Level Integration Tests (Playwright) using Request Interception (Mocking).
- Why Mocking? We want to verify the frontend's behavior (sending 1 request vs 2) without needing test accounts or hitting the real Production DB.
- Method: Intercept the
POST /graphqlrequest in Playwright. - Assertion: Verify that clicking "Save" triggers exactly one GraphQL mutation with the expected payload.
- Anti-Pattern Check: Assert that no subsequent requests immediately follow that overwrite the data (e.g., check for a second request with
calories: 0).
// Example Playwright Assertion (Mocked)
await page.route('**/graphql', route => {
// Mock the response so we don't hit the real DB
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
updateUserTargets: {
...
}
}
})
});
});
const requestPromise = page.waitForRequest(req =>
req.url().includes('graphql') &&
req.postDataJSON().operationName === 'UpdateUserTargets'
);
await page.click('button:has-text("Done")');
const request = await requestPromise;
// Assert: Payload is correct
expect(request.postDataJSON().variables.calories).toBe(1500);
// Assert: No double-fire (Wait 500ms to ensure no second request follows)
// ... logic to ensure no 2nd request ...