A screen that works for one person can fail when many people act on it at once. The first break is often not slow rendering: it is a user action that produces an uncertain result, followed by another click, another request, or an unsafe retry. I would treat the boundary between a button and a server write as a scaling problem before tuning the UI framework.
The first scale failure is often a duplicated decision, not a slow component
Imagine an admin changing a member’s role from viewer to editor. They press Save, see no response, and press it again. In a local demo, the request completes before the second click. Under load, a slower database query or a busy network gives that second click time to become another write. The screen may eventually show “Saved,” but that message cannot tell the admin whether one change happened, two requests raced, or the later request overwrote newer data.
That is why I would challenge Test the UI Framework Before You Estimate the Build as the end of a framework trial: rendering a form proves less than completing its write safely while the request is delayed. A junior developer can test this without building a load-testing platform. Pick one consequential action, delay its response, click again, navigate away, and return. Ask what the server stored and what the user believes happened. If those answers differ, faster rendering will not fix the failure.
At greater scale, the window for uncertainty appears more often because more requests compete for database connections and more users encounter ordinary network variation. That does not mean every slow response causes a duplicate write; it means a design that depends on quick responses will be exercised outside its happy path more frequently. A staging measurement of the p95 write latency is useful here: it tells you how long the slowest ordinary requests leave the user waiting, rather than hiding them inside an average. Record it for the specific action, not for an entire page whose image downloads may obscure the write.
The UI failure is also a communication failure. A spinner that vanishes after a timeout can look like a rejected change even when the server has committed it. An optimistic role label can look like a committed change when the server has rejected it. The 7 UX mistakes that page DevOps at 2 a.m. and their cost connects confusing feedback to operational pain, but I would measure the uncertain write behind the screen first because a clear error message cannot undo a duplicate operation.
Use distinct states in the interface: “Saving” means the outcome is pending, “Saved” means the server confirmed it, and “Could not confirm” means the client does not know. The last state matters because a network failure can occur after the server commits but before the response reaches the browser. Calling that case “Failed” invites a retry that may repeat a successful operation.
A disabled button does not make a write safe to retry
Disable Save while its request is pending because that prevents an easy double-click in the same open tab. Do not treat the disabled button as your duplicate-write protection, because a refresh, another tab, or a client retry bypasses it. The server needs a rule for repeated submissions that the browser cannot enforce on its own.
For an operation that creates a resource or triggers an external effect, send a client-generated idempotency key with the request and store its result against that key on the server. A repeated request with the same key should return the recorded outcome rather than perform the effect again. Decide how long to retain keys according to the longest realistic retry window; a short expiration is cheap but can turn a late retry back into a fresh write. If you use PostgreSQL 16, a UNIQUE constraint on the key and INSERT … ON CONFLICT can arbitrate concurrent submissions more reliably than a “check, then insert” sequence, because both requests may pass the check before either inserts.
Idempotency and conflicting edits solve different problems. If two admins load the same role and make different changes, returning the same result for repeated requests does not decide which edit is current. An HTTP ETag on the member representation and an If-Match header on the update let the server reject a write based on stale data. Under HTTP semantics documented in RFC 9110, 412 Precondition Failed tells the client that its condition no longer matches. A 409 Conflict may suit an application-level rule that cannot be satisfied even with a current representation. The UI must then show the current value and let the admin choose again; silently retrying the stale edit would defeat the check.
These safeguards have a cost, so apply them where the outcome warrants them. A draft search filter can tolerate another request because it has no lasting effect; changing permissions cannot, because an accidental update changes what someone can access. I would not add a generic “retry every failed POST” wrapper, because a lost response is not proof that the server skipped the operation. Retry only after the endpoint defines how repeated requests are recognized and what result the client should display.
Feedback still needs care once the server contract is sound. Keep the pending label visible until the operation has a confirmed outcome, and expose the result to assistive technology. WCAG 2.2 success criterion 4.1.3, Status Messages, is relevant because a status change should be perceivable without moving focus away from the control. An aria-live status region can announce “Role saved” or “Role changed elsewhere”; it should not announce success merely because the browser sent a request.
TanStack Query helps with server state, but plain fetch can win on a narrow path
React 19 with TanStack Query v5 wins when several screens read the same member record, because a mutation can invalidate the relevant query and bring those views back into agreement after a confirmed write. Its cost is another state model to learn: you must choose query keys carefully, handle mutation errors, and decide what to do if an optimistic update is rolled back. TanStack Query does not make a non-idempotent server operation safe simply because it manages a pending state.
React 19 with plain fetch wins when one screen owns one write path and a fresh server read follows it, because the request and its resulting state can remain easy to trace. Its cost grows as more components need the same data: each hand-built loading flag, cache, and refresh rule becomes another place for stale information to survive. Neither choice excuses an endpoint that accepts conflicting writes without saying so.
For a first implementation, write down the state transitions before choosing between them. The Save control starts idle, becomes pending, and then reaches confirmed, rejected, or unknown. “Rejected” covers a response such as 412 that the server deliberately returned. “Unknown” covers a connection loss with no trustworthy response. This distinction makes the code review more useful than asking whether the component has a loading boolean, because the boolean says nothing about the outcome after loading ends.
Keep the server response authoritative even if you use an optimistic update. An optimistic role label can make the interface feel responsive, but it needs a rollback path when the server rejects the edit and a reconciliation path when another admin changes the record. For an early-career developer, showing “Saving” beside the old confirmed value is often the better default for permission changes, because it avoids presenting an uncommitted privilege change as fact. If user testing shows that the wait is disruptive, add optimism with explicit rollback tests rather than assuming the faster-looking screen is safer.
Name the failure you expect each tool to catch. A TypeScript union for the four client states can prevent a component from accidentally treating “unknown” as “confirmed,” because the render logic must handle both cases. It cannot establish what PostgreSQL committed after a connection dropped. That question needs a server-side result lookup, a stable request identifier, or both. Keeping those responsibilities separate makes the system easier to debug when the UI and database disagree.
A failure drill should count writes, not just screenshots
Start the drill with a browser test that makes a request slow enough for a second action to be plausible. Playwright can intercept the request while the app runs locally; the following test assumes a member page and a Save role button exist. Save it as a Playwright test in the project and run it against the local app. The 800 ms delay is a value to tune for this drill, not a recommended production timeout.
import { test, expect } from '@playwright/test';
test('double-click sends one role update', async ({ page }) => {
let writes = 0;
await page.route('**/api/members/42/role', async route => {
writes++;
await new Promise(resolve => setTimeout(resolve, 800));
await route.fulfill({ status: 200, body: '{"role":"editor"}' });
});
await page.goto('http://localhost:3000/members/42');
await page.getByRole('button', { name: 'Save role' }).dblclick();
await page.waitForTimeout(1000);
expect(writes).toBe(1);
});
This test checks a measured result—one intercepted write after a double-click—but it cannot prove server idempotency because the route replaces the real endpoint. Add a separate API test that sends the same idempotency key concurrently and verifies one stored effect. Then test two different edits against the same old ETag and verify that the stale edit is rejected. Mock Service Worker v2 can reproduce delayed and rejected responses during component tests, while Playwright exercises the browser interaction; each earns its place by testing a different boundary.
Keep test waits and operational limits separate. Playwright documents a default test timeout of 30 seconds; that vendor-published limit is a runner setting, not a promise users should wait that long for Save. Choose the UI’s pending and uncertainty messages from observed response behavior and user expectations, then test them explicitly. A fixed delay is useful for exposing a race, but a test that passes only because a machine happened to be slow is not a dependable regression check.
Once the action is in use, make it traceable. OpenTelemetry JavaScript can attach a trace to the browser request, and W3C Trace Context’s traceparent header can carry that context to the server. Record the operation name, request outcome, and whether a key was replayed; avoid putting the member’s name or the full request body in telemetry because those values add exposure without helping you count duplicates. A Prometheus counter for conflicting updates and another for idempotency-key replays can distinguish normal protection from a surge in uncertain user actions.
As a provisional alert threshold, investigate if more than 0.5% of role-write attempts end with an unknown client outcome during a sustained window; tune that percentage after you have a baseline, because a small team’s traffic can make one incident look like a dramatic rate change. In Grafana, pair that rate with write latency and server errors rather than graphing it alone. If latency rises first and unknown outcomes follow, the dashboard gives you a reason to inspect the request path instead of blaming the Save button.
Start with the write that would be hardest to explain twice
Tomorrow, choose one lasting action in your app and draw its path from click to committed record. Write down what the user sees if the response is slow, rejected, or lost. Then run a delayed double-click test and check the stored result, not only the screen. If you cannot tell whether the action happened once, fix that contract before making the component faster.


