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 each response filter exposes to script A five-row matrix of Response properties against the four filtered response types defined by the Fetch Standard. The basic and cors columns expose a real status, readable headers and a readable body, while the opaque and opaqueredirect columns report status zero, an empty header list and a null body. What JavaScript can read from the reply basic (same-origin) cors (grant matched) opaque (no-cors mode) opaqueredirect (manual redirect) response.type basic cors opaque opaqueredirect response.status the real code the real code 0 0 response.ok true or false true or false always false always false response.headers all of them safelist + exposed none at all none at all response.body readable readable null null The filter is applied before your code runs — an opaque response has nothing to unwrap

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.

A no-cors fetch, stage by stage Five stages run down the left column from the fetch call to the filtered response, each linked by an arrow to a note on the right describing what the browser strips or keeps. The request succeeds on the network and only the final filtering stage removes the status, headers and body. 1. Script calls fetch() with mode: 'no-cors' No preflight is scheduled. The browser never asks the server for permission, because it has already decided not to give you the answer. 2. Header list is filtered to the CORS safelist Authorization and every custom header you set is discarded without a warning; a method other than GET, HEAD or POST rejects the call. 3. The request goes out on the network Cookies follow the credentials option, so the server may run a fully authenticated, state-changing operation on the user's behalf. 4. The server answers 200 with a complete body Nothing has gone wrong yet. The bytes are on the wire, the response headers are complete, and the access log records an ordinary hit. 5. The response is filtered to opaque status becomes 0, headers become an empty list and the body becomes null. This single step is where the data you wanted disappears. Only stage 5 is a restriction on you; stages 2 and 3 are restrictions the server never learns about

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.

Is no-cors the right mode for this call? Three stacked questions ask whether the code needs the response contents, whether the method and headers fit the safelist, and whether only the side effect matters. Each question branches right to a reason no-cors is wrong, and answering the last one yes leads to the single case where no-cors is appropriate. Does the code need the status, the headers or the body of this response? no-cors can supply none of them. Fix the grant on the server instead. yes no Is it a GET, HEAD or POST carrying only CORS-safelisted request headers? The header is dropped or the call rejects. no-cors is not a workaround. no yes Is the point purely the side effect — warm an edge cache, prime a connection? Then no-cors buys nothing. Use cors mode so failures stay visible. no yes no-cors is the right mode: an accepted fire-and-forget with no readable result. Three questions separate a deliberate fire-and-forget from a CORS error that was merely muted

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.

Storage quota consumed by cors versus opaque cache entries The upper bar holds twelve small segments representing twelve cached CORS responses of twelve kilobytes each, using a trivial share of the quota. The lower bar of the same width holds only three wide segments, because each opaque entry is padded to roughly seven megabytes. The same 12 KB asset, cached two different ways, against one origin quota 12 entries fetched in cors mode — 144 KB of real bytes, counted as 144 KB 3 entries fetched in no-cors mode — 36 KB of real bytes, counted as about 21 MB opaque entry, padded opaque entry, padded opaque entry, padded Padding exists so a page cannot measure a cross-origin body by watching its own quota shrink

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

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.