Skip to content

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:

  1. setTargets fires. It uses the current state (e.g., calories: 0 -> calories: 1500).
  2. setDisplayUnit fires synchronously after. It also uses the current state (which is still calories: 0 because React hasn't re-rendered yet).
  3. 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:

// SettingsModal.jsx
updateSettings({
    ...localTargets,
    displayUnit: localUnit
});

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 /graphql request 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 ...