Opaque Responses & no-cors Mode
A CORS failure is loud: a red console line, a named header, a rule you can look up. mode: 'no-cors' is the quiet failure. The request leaves the browser, the server answers normally, the promise resolves, and the object you get back reports success on nothing at all — status 0, no headers, no body. Developers reach for it because a search result promised it “fixes CORS”, and then spend an afternoon working out why res.json() throws on a response the server logged as a healthy 200. This page is part of Core CORS Mechanics & Same-Origin Policy Fundamentals, and it covers the half of the Fetch Standard that has no error message: opaque responses, the request restrictions no-cors imposes, canvas tainting, and the crossorigin content attribute that opts an element back into a readable exchange.
The rule underneath all of it is short. The WHATWG Fetch Standard does not hand your script the response the network produced. It hands you a filtered response — a wrapper that decides which parts of the internal response are visible based on how the request was made. mode: 'cors' with a matching grant produces a CORS filtered response. mode: 'no-cors' produces an opaque filtered response, and opaque means exactly what it sounds like.
That distinction is deliberate, and it predates fetch() by two decades. The browser has always been willing to send a cross-origin request — an <img> tag pointed at another domain has worked since the beginning of the web — and it has never been willing to let the requesting page read what came back without permission. If reading were free, any page you visited could issue a credentialed request to your bank, your webmail, or your company intranet and exfiltrate the response. Filtering is the mechanism that keeps “the request happened” and “you may read the reply” as two separate facts, and no-cors is the mode in which the first is true and the second is permanently false.
The Four Response Filters
Every fetch() result you have ever inspected is one of four filtered shapes. response.type tells you which one you are holding, and it is the first thing to log when a response makes no sense.
response.type |
Produced by | Status visible | Headers visible | Body readable |
|---|---|---|---|---|
basic |
A same-origin request, or a data: / blob: URL |
Yes, the real code | All of them except Set-Cookie |
Yes |
cors |
A cross-origin request in cors mode that the server approved |
Yes, the real code | CORS-safelisted response headers plus anything named in Access-Control-Expose-Headers |
Yes |
opaque |
A cross-origin request in no-cors mode |
No — always 0 |
None — an empty Headers object |
No — null |
opaqueredirect |
A request with redirect: 'manual' that received a 3xx |
No — always 0 |
None | No |
Two of those four are placeholders. Reading them is not a bug you can work around; the emptiness is the specified behaviour.
What no-cors Changes About the Request
The mode is not only a response filter. Setting mode: 'no-cors' also puts the request itself under the no-CORS-safelisted guard, which quietly rewrites what you asked for. This is where the second class of surprise lives: your Authorization header never leaves the browser, and nothing tells you.
| Request feature | Permitted under no-cors |
Behaviour when you exceed it |
|---|---|---|
| Method | GET, HEAD, POST only |
fetch() rejects with a TypeError before any network activity |
Content-Type |
text/plain, application/x-www-form-urlencoded, multipart/form-data |
The header set is a silent no-op; the request ships with the browser’s default type |
Authorization |
Not permitted | Silently discarded — the server sees an anonymous request |
Custom X-* headers |
Not permitted | Silently discarded, so header-based routing and tracing break |
Accept, Accept-Language, Content-Language |
Permitted (they are CORS-safelisted) | Value-length and character restrictions from the safelist still apply |
credentials |
omit, same-origin (default), include |
All accepted; cookies flow normally when set to include |
redirect |
follow (default), manual, error |
manual yields an opaqueredirect response rather than a followable one |
The silent header drops are specified rather than accidental. A cross-origin request that could carry an arbitrary header would be a request the server never agreed to receive, which is the entire reason simple and preflighted requests are separated in the first place. Under no-cors there is no preflight to negotiate the extras, so the extras are removed.
Walking one call through the pipeline makes the split between “the exchange worked” and “you can read the exchange” concrete.
Where no-cors Happens Without You Asking For It
fetch(url, { mode: 'no-cors' }) is the explicit form, but the overwhelming majority of no-CORS requests on the web are issued by markup that never mentions CORS at all. Every classic embedding element defaults to no-CORS mode, which is exactly why embedding has always worked across origins while reading has not. The Same-Origin Policy permits the load; it withholds the contents.
| Element | Default request mode | What the page may do with the result | How to opt into a readable exchange |
|---|---|---|---|
<img src> |
no-CORS | Paint it; read naturalWidth and naturalHeight |
Add crossorigin and serve Access-Control-Allow-Origin |
<script src> |
no-CORS | Execute it; error events are censored to "Script error." |
Add crossorigin to get real messages, stack traces and Subresource Integrity |
<link rel="stylesheet"> |
no-CORS | Apply the rules; cssRules throws a SecurityError |
Add crossorigin and grant the origin |
<video> / <audio> |
no-CORS | Play it; drawing a frame taints a canvas | Add crossorigin and grant the origin |
<link rel="preload" as="font"> |
Always CORS | Font loading is CORS-only by specification | The grant is mandatory, not optional |
<iframe src> |
Navigation | Display it; the document is unreachable from script | Not a CORS problem — use postMessage |
Two consequences follow. First, a resource can be visible on the page and still be entirely unreadable to your code, which is why “but the image is right there” is never an argument against a taint error. Second, adding the crossorigin attribute changes the network request, not just the JavaScript surface: the browser now sends an Origin header and demands a matching grant, and a server that does not send one turns a previously working element into a broken one. That trade-off is the subject of Using the crossorigin Attribute on script and img.
The "Script error." row deserves particular attention on any team that runs client-side error reporting. When an uncaught exception originates in a script fetched in no-CORS mode, the browser replaces the message, the filename and the line number with placeholders before handing the event to window.onerror. Your monitoring dashboard fills with identical, useless entries. One attribute plus one response header restores the whole stack trace, which is often the highest-value CORS change a team can make in an afternoon.
Step-by-Step: Replacing no-cors With a Working Configuration
Most no-cors in production code is a leftover from debugging. The path back is short, and it ends with a server that grants your origin explicitly. The examples below use a storefront at https://shop.example.net reading a catalogue API at https://catalog.example.net.
1. Prove that the response really is opaque
Before changing anything, confirm the diagnosis. response.type is the single field that settles it.
const res = await fetch('https://catalog.example.net/v2/items', { mode: 'no-cors' });
console.log(res.type); // "opaque"
console.log(res.status); // 0
console.log(res.ok); // false
console.log([...res.headers]); // []
console.log(res.body); // null
// This is the line that throws — the body is empty, not malformed JSON
await res.json(); // SyntaxError: Unexpected end of JSON input
A guard clause is worth keeping permanently in shared fetch wrappers, because an opaque response is the one failure that never reaches a catch block on its own:
async function readJson(url, init) {
const res = await fetch(url, init);
if (res.type === 'opaque') {
throw new Error(`Opaque response from ${url}: the request used no-cors mode, ` +
'so the body can never be read. Configure CORS on the server.');
}
if (!res.ok) throw new Error(`${res.status} from ${url}`);
return res.json();
}
2. Remove the mode and read the real error
Delete mode: 'no-cors'. The default mode for a fetch() from a document is cors, so the call reverts to a negotiated request and the browser starts telling you what is wrong again. That message is the actual work item — decoding browser CORS error messages maps each wording to the rule it came from.
// Before: silent, useless, and still sends the request
const res = await fetch('https://catalog.example.net/v2/items', { mode: 'no-cors' });
// After: loud, diagnosable, and fixable on the server
const res = await fetch('https://catalog.example.net/v2/items', {
headers: { Accept: 'application/json' },
});
3. Grant the origin — Express
A minimal, allowlist-driven grant is a dozen lines. Set the header only for origins you recognise, and add Vary: Origin so shared caches keep the answers apart.
const express = require('express');
const app = express();
const ALLOWED = new Set(['https://shop.example.net', 'https://staging-shop.example.net']);
app.use((req, res, next) => {
const origin = req.get('Origin');
res.setHeader('Vary', 'Origin');
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Expose-Headers', 'X-Catalog-Version, X-Request-Id');
}
next();
});
app.options('/v2/*', (req, res) => {
const origin = req.get('Origin');
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '600');
return res.sendStatus(204);
}
return res.sendStatus(403);
});
app.get('/v2/items', (req, res) => {
res.setHeader('X-Catalog-Version', '2026.08');
res.json({ items: [{ sku: 'A-1042', price: 1899 }] });
});
app.listen(8080);
Access-Control-Expose-Headers matters more than it looks. Without it, a cors filtered response hides X-Catalog-Version exactly the way an opaque response hides everything — and a developer who only checks response.headers.get() concludes the mode is still wrong. The full list of directives is catalogued in Access-Control-* Header Directives.
4. Grant the origin — Nginx
When the API sits behind a reverse proxy, a map keeps the allowlist in one place and avoids emitting a blank header for strangers.
map $http_origin $catalog_cors_origin {
default "";
"https://shop.example.net" $http_origin;
"https://staging-shop.example.net" $http_origin;
}
server {
listen 443 ssl;
server_name catalog.example.net;
location /v2/ {
add_header Vary Origin always;
if ($catalog_cors_origin) {
add_header Access-Control-Allow-Origin $catalog_cors_origin always;
add_header Access-Control-Expose-Headers "X-Catalog-Version, X-Request-Id" always;
}
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
proxy_pass http://catalog_upstream;
}
}
5. Keep no-cors only where it earns its place
no-cors is not always wrong. It is the correct mode when the response genuinely does not matter and only the side effect does: warming a CDN edge, priming a connection before a user action, or precaching a third-party asset in a service worker. The test is whether you would ever branch on the result.
A legitimate use looks like this — a service worker warming its cache with a font it will only ever replay, never inspect:
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open('third-party-v1');
const request = new Request('https://fonts.example.net/inter-var.woff2', {
mode: 'no-cors',
credentials: 'omit',
});
const response = await fetch(request);
// response.type === "opaque"; we cannot check response.ok, so this is a leap of faith
await cache.put(request, response);
})());
});
Edge Cases and Security Boundaries
Opaque responses cost far more storage than they contain
Cache Storage pads every opaque entry to a large fixed size so that a page cannot binary-search a cross-origin resource’s real length by watching its own quota. Chromium’s padding is roughly seven megabytes per opaque entry. A dozen opaque fonts and sprites will exhaust a modest origin quota and start evicting entries you actually needed, and because the entries are opaque you cannot inspect them to work out which ones are junk.
opaqueredirect and the manual redirect trap
Setting redirect: 'manual' on a cross-origin request does not let you read the Location header. It produces an opaqueredirect response — status 0, no headers — precisely so that a page cannot use redirect chains as a cross-origin oracle. If you need to know where a redirect leads, the destination server has to expose that with Access-Control-Expose-Headers: Location on a normal cors request.
Canvas tainting is the same rule wearing different clothes
An img element loaded without a crossorigin attribute performs a no-CORS fetch. Drawing that image into a canvas clears the canvas’s origin-clean flag, and every pixel-reading method then throws a SecurityError. This is the same “the bytes arrived but you may not read them” boundary, applied to a bitmap instead of a JSON body — worked through in Fixing Tainted Canvas Cross-Origin Image Errors.
Credentials turn a quiet call into an unverifiable one
fetch(url, { mode: 'no-cors', credentials: 'include' }) is legal. The browser sends the user’s cookies, the server authenticates the request, and any state change it performs is real. Your script simply cannot confirm it happened. This is a CSRF-shaped hazard: the endpoint should require something a no-cors request cannot supply — a custom header, or a token in a body the server validates. The credential rules that make this dangerous are covered in Credential Sharing & Security Boundaries in CORS.
Cross-origin isolation makes the boundary stricter, not looser
Under Cross-Origin-Embedder-Policy: require-corp, a no-CORS subresource load is blocked outright unless the response carries Cross-Origin-Resource-Policy. An opaque response is no longer a quiet degradation; it becomes a hard load failure. Cross-Origin Isolation with COOP and COEP walks through the resulting fixes.
Proxy and CDN Interaction
Intermediaries cannot see the request mode — no-cors never appears on the wire — but they change the outcome anyway.
A shared cache can hand a no-CORS response to a CORS request. The HTTP cache stores what the first requester received. If an asset was first fetched by a plain img tag with no crossorigin attribute, the stored response has no Access-Control-Allow-Origin. A later CORS-mode request for the same URL hits that entry and fails, even though the server is configured correctly. Vary: Origin on the asset response splits the entries; correct handling is detailed in How to Fix Missing Vary: Origin Header Breaking CORS Cache Segmentation.
A CDN that adds CORS headers only on OPTIONS makes the whole class of bug worse. A no-cors request never triggers a preflight, so an edge rule keyed on the OPTIONS method never runs, and the asset ships without a grant.
Image and font CDNs frequently strip unknown request headers. Because no-cors already stripped everything non-safelisted, the symptom is identical whether the browser or the edge removed the header — check the origin server’s log to tell them apart, ideally alongside a synthetic request built with curl.
Object storage buckets are the most common source of “it works locally”. An asset served straight from a bucket usually has no CORS rule at all, because the default configuration is designed for browser navigation rather than for scripted reads. A local development server serving the same file from the same origin produces a basic response with every header readable, so the code passes review and fails the moment the asset moves behind its production hostname. Adding an explicit rule that names the site origin — rather than reaching for a wildcard, whose trade-offs are set out in Wildcard CORS Risks and Safe Origin Allowlisting — makes the two environments agree.
Transform rules and header-rewrite features at the edge run after the cache. A rule that injects Access-Control-Allow-Origin on the way out will decorate a response that was cached without it, which fixes the symptom while leaving the cached entry wrong for every layer that does not run the rule. Set the header at the origin, cache it with the response, and use the edge only to verify it.
DevTools and curl Verification Checklist
curl -sI https://catalog.example.net/v2/items \ -H 'Origin: https://shop.example.net' | grep -i 'access-control\|vary'curl -sI https://catalog.example.net/v2/items \ -H 'Origin: https://not-allowed.example.org' | grep -ci 'access-control-allow-origin'
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Adding mode: 'no-cors' to silence a console error |
The request still leaves the browser and the data is still unreadable; the diagnostic message is lost | Remove the mode, read the real error, and grant the origin server-side |
Expecting Authorization to survive a no-cors request |
The header is discarded silently, so the API sees an anonymous caller and returns a 401 body you cannot read |
Use mode: 'cors' and allow the header via Access-Control-Allow-Headers |
Branching on response.ok after a no-cors fetch |
ok is always false, so every call looks like a failure and retry logic loops forever |
Test response.type === 'opaque' explicitly and treat it as a configuration error |
| Precaching many third-party assets opaquely in a service worker | Each entry is padded to roughly 7 MB, exhausting the origin quota and evicting genuinely needed entries | Serve the assets with Access-Control-Allow-Origin and cache them in cors mode |
Using redirect: 'manual' to inspect a cross-origin redirect target |
The result is an opaqueredirect response with no Location header to read |
Expose Location with Access-Control-Expose-Headers on a normal cors request |
Assuming a no-cors request is harmless because nothing is readable |
Cookies still flow and state-changing endpoints still execute | Require a custom header or a validated token that a no-cors request cannot supply |
FAQ
Does mode: ‘no-cors’ bypass CORS?
No. It opts out of asking for access rather than gaining it. The browser still refuses to expose the response to your script; it simply stops printing a console error and hands you an opaque placeholder instead. The only way to read a cross-origin response is an Access-Control-Allow-Origin header that names your origin, which is why the fix always lives on the server rather than in the fetch options.
Why is response.status 0 when the server clearly returned 200?
The Fetch Standard filters the internal response into an opaque filtered response before your script sees it. That filter sets the type to opaque, the status to 0, the status message to the empty string, the header list to empty, and the body to null. The real 200 exists in the network layer and in your server log, and the DevTools Network panel will show it — but it never crosses into script.
Can I read an opaque response from a service worker?
A service worker can store an opaque response in Cache Storage and replay it for a matching request, so precaching third-party assets works. It cannot inspect the status, headers, or body, so it cannot tell a cached 404 or an intercepted captive-portal page from a valid asset, and each opaque entry is padded to a large fixed size against the origin’s storage quota.
Is it safe to send credentials with a no-cors request?
It is allowed and it is rarely wise. With credentials: 'include', the browser attaches cookies and the server may run a fully authenticated, state-changing operation while your script learns nothing about the outcome. Treat such a call as an unverifiable side effect and protect the endpoint with a CSRF token or a custom header that a no-cors request cannot carry.