Start with the operation count
Our local API created an operation and sent 201. The browser's fetch() rejected with TypeError, leaving the page without its receipt. Preflight had passed. The final response was missing Access-Control-Allow-Origin.
Repeating the request created another operation. Both promises rejected.
That is the diagnostic problem this lab follows. A JavaScript error alone does not reveal whether a write reached its handler. Across ten native Chromium cases, the same error class accompanied zero, one or two created operations. Start by correlating the browser exchange with the API's operation record before deciding to retry.
The server in this experiment stores operation rows in process memory. Those rows show that the handler executed its side effect. They establish neither durable database commit nor recovery after a server crash.
Trace both CORS decisions
For the JSON POST in our first case, Chromium sent OPTIONS. The API returned 204 without the required origin permission, and Chromium sent no POST. The next case returned a valid preflight response. Chromium then sent POST with the synthetic cookie, the handler created op-1 and the API returned 201. JavaScript still received an error because that final response lacked the origin header.
An origin includes scheme, host and port. Compare the page's actual origin against an explicit server allowlist. For a permitted credentialed request, the response names that origin and uses Access-Control-Allow-Credentials: true. The browser evaluates the actual response as well as any preflight exchange. WHATWG Fetch CORS protocol
This illustrative response policy assumes the deployment has already approved https://app.example as a frontend origin. The fixture uses its own dynamically allocated loopback origin instead.
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Credentials: true
Vary: OriginThe preflight response separately permits POST and the requested header names. Our fixture advertises Content-Type and Idempotency-Key, with Access-Control-Max-Age: 0 so repeated attempts exercise a fresh preflight. An ordinary allowed JSON response still needs the origin and credential response fields.
In the wildcard case, preflight correctly named the frontend origin. Only the actual response changed to Access-Control-Allow-Origin: *. Chromium sent the POST, the handler created one operation and the browser blocked access to the reply. The missing-credentials-header case had the same server outcome. Preserve both response header sets when diagnosing this branch.
Ten browser cases, one server observation
Each case runs in a fresh browser context with normal web security. A frontend and API listen on different loopback ports. Native page JavaScript performs the fetch. The server records OPTIONS and POST separately, including cookie presence, response status and the operation count after handling the request.
| Case | OPTIONS / POST | API POST status | JavaScript result | Created |
|---|---|---|---|---|
| Denied preflight | 1 / 0 | No POST | TypeError | 0 |
| Missing origin header | 1 / 1 | 201 | TypeError | 1 |
| Wildcard with credentials | 1 / 1 | 201 | TypeError | 1 |
| Missing credentials header | 1 / 1 | 201 | TypeError | 1 |
| Readable success | 1 / 1 | 201 | Resolved, ok true | 1 |
| Simple POST, hidden reply | 0 / 1 | 201 | TypeError | 1 |
| Blind retry, two calls | 2 / 2 | 201, 201 | TypeError twice | 2 |
| Stable-key retry, two calls | 2 / 2 | 201, 201 | TypeError twice | 1 |
| Readable validation error | 1 / 1 | 422 | Resolved, ok false | 0 |
| Hidden validation error | 1 / 1 | 422 | TypeError | 0 |
The status column comes from the API trace. For the TypeError rows, application JavaScript could not read that HTTP status through the failed fetch. This separates our observation as the test operator from what the frontend actually knew.
The chart selects five cases whose browser result was TypeError. Their operation counts are 0, 1, 2, 1 and 0. These counts describe the specified attempts in this fixture, without estimating a production frequency.
Download the Node experiment and recorded results. Place the script in a project with Playwright and its Chromium browser installed, then run node experiment.mjs > results.json. Our two runs used Node 24.20.0, Playwright 1.62.1 and Chromium 151.0.7922.34 and produced byte-identical JSON. Dynamic origin values are checked against the actual frontend origin and normalized to exact-ui in the saved trace.
The runner uses page.evaluate() to execute browser fetch, rather than Playwright's HTTP request client. That preserves native browser CORS behavior. Expected CORS failures are intentional inputs to this lab. Playwright browser evaluation
Check cookie scope before changing the policy
The fixture's POST requests include credentials: 'include'. Every observed POST carries the synthetic HttpOnly session cookie, while its OPTIONS request carries no cookie. The frontend and API share the same host and scheme but use different ports, making this a same-site, cross-origin test.
That boundary matters. A cross-site deployment can have a different cookie result because SameSite and browser cookie policy also apply. Before changing CORS headers, check whether the intended cookie was actually sent. credentials: 'include' does not override a cookie's cross-site restrictions. MDN on fetch credentials
The simple-POST case changes the content type to text/plain and adds no custom request header. Chromium sends no OPTIONS, but it still sends the cookie and POST body. The handler creates one operation before the browser rejects access to its reply. Our fixture intentionally accepts that body to expose this path. A real API should enforce its own accepted content types and validate the payload.
Treat server authorization and CSRF defenses as independent write controls. The presence or absence of preflight cannot establish the caller's right to change a resource. MDN explains why form-like cross-origin requests can be sent without preflight and still require server-side CSRF protection. MDN simple requests
Retry with a recoverable operation identity
Both calls in the blind-retry case have the same body. The server creates op-1 and op-2, and both browser attempts fail to expose their responses. In the stable-key case, both requests carry Idempotency-Key: fixture-operation. The local handler recognizes the repeated body and returns the original op-1 receipt. Only one row is created, although JavaScript still sees two errors.
The stable key limits this fixture's duplicate effect. It does not repair its response policy.
For a product write, preserve an operation identity before sending the request and retain it across recovery. A status lookup or retry path must let the client discover the authoritative result through a permitted response. The deeper API idempotency contract covers request identity and retry cases. This lab's in-memory map tests sequential repeats within one process, with no persistence, expiry or concurrent-writer guarantee.
Changing to mode: 'no-cors' prevents ordinary JavaScript access to the receipt through an opaque response. It does not provide the reconciliation signal this workflow needs. Likewise, a successful command-line HTTP call cannot prove that a browser will expose the reply. Use a real browser to reproduce the original page origin and credential mode. MDN on request mode and opaque responses
Make error responses readable too
Our readable-validation case returns 422 with the permitted origin and credential headers. Fetch resolves, response.ok is false and JavaScript reads the fixture's validation message. The hidden-validation case creates no operation and returns the same status, but its missing origin header makes the browser report TypeError instead.
A rejected fetch and an application-level rejection therefore need different evidence. Fetch exposes HTTP error responses when the response is accessible. Inspect response.ok or the status after resolution, and handle network or policy errors separately. MDN response handling
Apply the approved response policy to validation, authentication and exception paths as well as normal success. In the deployed route, inspect responses produced by a reverse proxy or gateway too. A browser-facing generic error can hide a useful server rejection without implying that the write succeeded.
If the receipt lives in a custom response header, explicitly expose that name. Our readable-success case exposes X-Operation-Id, and JavaScript reads op-1 from both that header and the JSON body. Access-Control-Allow-Headers permits request headers during preflight. Access-Control-Expose-Headers controls which additional response headers JavaScript may read. WHATWG CORS response fields
Our server chooses its approved origin and sends Vary: Origin. Review the corresponding cache behavior when a proxy or CDN shares responses between origins. Reflecting every incoming Origin without checking an allowlist would change the access policy. The fixture's zero preflight max-age and fresh contexts isolate its observations from a previously cached preflight grant.
Release checks for the browser and API together
Use an operation identifier that can correlate the UI attempt with the API's protected record. In browser developer tools, distinguish the OPTIONS row from the actual POST and inspect both response paths. Keep sensitive payloads and session values out of general diagnostic logs.
- Reproduce the failure from the intended frontend origin with the intended credential mode. Record whether OPTIONS and POST occurred, rather than inferring either from a console message.
- Check the API's authoritative operation outcome. If it is uncertain, preserve the pending identity while reconciling. Keep UI recovery from issuing an unrelated new write.
- Verify a readable success and a readable validation rejection. Then test a denied preflight and an intentionally hidden final response, asserting the server's side effect in each case.
- Exercise the deployed cookie and routing boundaries. Include relevant cross-site cookie behavior, error middleware and cached responses, and keep the retry identity stable throughout recovery.
The executed lab uses one Chromium build over local HTTP. It has no proxy, TLS client certificate, redirect or service worker. Cross-site cookie policy and other browser engines need their own deployment checks. Its exact assertion is narrower and inspectable: a denied preflight sends no POST, while a hidden 201 response can leave a completed API operation behind.
Sources
Documentation checked .

Continue the conversation
Comments (0)