What no-cors Mode Does to Your Response

Failure symptom:

Uncaught (in promise) SyntaxError: Unexpected end of JSON input
    at readMetrics (dashboard.js:42:26)

> console.dir(res)
Response
  type: "opaque"
  url: ""
  status: 0
  ok: false
  statusText: ""
  headers: Headers {}
  body: null

There is no CORS message in the console, no red network row, and the server’s access log shows a clean 200 with a four-kilobyte body. Everything worked except the part where you read it.

Root Cause

mode: 'no-cors' tells the Fetch Standard to build an opaque filtered response around whatever comes back. The filter is not an error handler; it is a specified transformation applied to a perfectly successful response. It rewrites type to opaque, status to 0, statusText to the empty string, url to the empty string, the header list to an empty Headers object, and the body to null. Your promise therefore resolves — nothing failed — and the first line of code that touches the payload is the one that blows up. res.json() reads an empty stream, and the JSON parser reports the empty string as a syntax error, which is why the message names JSON rather than CORS.

This page belongs to Opaque Responses & no-cors Mode, which covers the wider family of unreadable-but-successful cross-origin exchanges.

It helps to know how few ways fetch() has of telling you something went wrong. The promise rejects with a TypeError for exactly two classes of problem: the request could not be made at all (DNS failure, refused connection, mixed content, a blocked method under no-cors), or a CORS check failed while the mode required one. Everything else — a 404, a 500, an empty body, an opaque filter — resolves. That is why an opaque response is the hardest CORS symptom to recognise: it takes the one code path where the browser has decided there is nothing to report.

The Response object versus the response on the wire The left panel lists every property of the opaque Response object handed to script, all of them empty or zero. The right panel lists the real HTTP response the server produced, with a 200 status, a content type and a body length, showing that only the script-facing view was emptied. What JavaScript is handed type: "opaque" status: 0 statusText: "" ok: false url: "" redirected: false headers: Headers {} body: null Every field is a placeholder, not an error code What the server actually sent HTTP/2 200 content-type: application/json content-length: 4213 cache-control: max-age=60 x-request-id: 8f2c41ba etag: "9d1e-3b2" {"series":[{"t":1754... ...4213 bytes of body]} Complete, correct, and recorded in the access log The exchange on the right happened; the object on the left is all your code is permitted to see

Prerequisite State

Step-by-Step Fix

Step 1 — Confirm the response is opaque, not empty

An empty body and an opaque body look identical at the call site. response.type is the only property that distinguishes them, and it is set by the filter rather than by the server.

const res = await fetch('https://metrics.example.io/v1/series?range=24h', {
  mode: 'no-cors',
});

console.log(res.type, res.status, res.ok, [...res.headers].length);
// "opaque" 0 false 0

If type is cors and status is 200 but a header you expected is missing, the mode is fine and the problem is a missing Access-Control-Expose-Headers — a different fix, described in step 4.

The Network panel timing bars settle any remaining doubt about whether the request “worked”. Every phase completes normally, including the download of all 4213 bytes; the filter runs after the last byte arrives and before the promise settles.

Where the opaque filter lands in the request timeline Five timing bars on a millisecond scale show the request stalling briefly, connecting, waiting for the first byte and downloading the full body by 192 milliseconds. A marker at the end of the download indicates the point at which the response filter runs and the promise resolves with an empty opaque object. The same request in the Network panel — every phase completes normally Stalled DNS, TCP and TLS Request sent Waiting for first byte Downloading 4213 bytes The filter runs here: the promise resolves with an empty shell 0 50 100 150 200 250 ms Nothing on this timeline failed — the bytes were paid for in full and then discarded at the boundary

Step 2 — Delete the mode and read the real error

Removing mode: 'no-cors' restores the default cors mode and, with it, the diagnostic the browser was withholding.

// The version that fails silently
const res = await fetch('https://metrics.example.io/v1/series?range=24h', {
  mode: 'no-cors',
});

// The version that tells you what is wrong
const res = await fetch('https://metrics.example.io/v1/series?range=24h', {
  headers: { Accept: 'application/json' },
});

The console now names the missing piece explicitly, for example that no Access-Control-Allow-Origin header is present on the requested resource. Reading that row in the Network panel is covered in Inspecting Preflight in the DevTools Network Panel.

Step 3 — Grant the origin on the API

The grant belongs on the server. This Fastify handler allowlists two origins and refuses to reflect anything else:

const Fastify = require('fastify');
const app = Fastify();

const ALLOWED = new Set([
  'https://dashboard.example.io',
  'https://dashboard-preview.example.io',
]);

app.addHook('onSend', async (request, reply) => {
  const origin = request.headers.origin;
  reply.header('Vary', 'Origin');
  if (origin && ALLOWED.has(origin)) {
    reply.header('Access-Control-Allow-Origin', origin);
  }
});

app.get('/v1/series', async (request, reply) => {
  reply.header('X-Series-Version', '4');
  return { series: [{ t: 1754300000, v: 91.4 }] };
});

app.listen({ port: 8081 });

Vary: Origin is set unconditionally, including on the rejection path, so a shared cache never replays a grant-less response to an origin that would have been allowed.

Note that this endpoint needs no OPTIONS handler. A GET carrying only an Accept header is a simple request, so the browser sends it directly and judges the grant on the actual response. Add preflight handling only when the client starts sending Authorization, a JSON Content-Type, or a method beyond GET, HEAD and POST — the boundary is mapped out in Simple vs Preflight Requests: CORS Mechanics. Shipping an unnecessary OPTIONS route is harmless but it hides which requirement actually applies, and it makes the eventual failure harder to place.

Step 4 — Expose the response headers the client reads

A cors filtered response only reveals the CORS-safelisted response headers — Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. Anything else must be named explicitly, or headers.get() returns null and you get a second, smaller version of the same confusion.

reply.header('Access-Control-Expose-Headers', 'X-Series-Version, X-Request-Id, ETag');
What no-cors mode permits on the request side Five rows cover method, Content-Type, Authorization, custom headers and credentials. Each row names the values permitted under no-cors mode and the behaviour when the limit is exceeded, which is usually a silent discard rather than an error. Request feature Permitted under no-cors What happens if you exceed it method GET, HEAD, POST fetch() rejects with a TypeError before any network activity Content-Type text/plain, form-urlencoded, multipart/form-data application/json is discarded; the server sees the default type Authorization never permitted set() is a no-op, so the API answers as if nobody signed in X-* custom headers never permitted discarded silently, so tracing and header routing stop working credentials omit, same-origin, include cookies still travel; only the reply is unreadable to your code Only the method limit raises an error — every other restriction is applied without telling you

Step 5 — Guard the wrapper so the mode cannot come back

no-cors reappears whenever someone is under deadline pressure. A single check in the shared fetch helper converts the silent version back into a loud one, permanently.

export async function apiFetch(url, init = {}) {
  if (init.mode === 'no-cors') {
    throw new Error(`Refusing no-cors for ${url}: the response would be unreadable.`);
  }
  const res = await fetch(url, init);
  if (res.type === 'opaque') {
    throw new Error(`Opaque response from ${url}. Configure Access-Control-Allow-Origin.`);
  }
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText} from ${url}`);
  }
  return res;
}

Verification

curl the endpoint with an Origin header and confirm the grant is present:

curl -sI 'https://metrics.example.io/v1/series?range=24h' \
  -H 'Origin: https://dashboard.example.io' | grep -i 'access-control\|vary'

Expected output includes access-control-allow-origin: https://dashboard.example.io, access-control-expose-headers: X-Series-Version, X-Request-Id, ETag, and vary: Origin.

DevTools check: reload with the Network panel open, select the /v1/series row, and read the Response Headers block. Then run (await fetch('https://metrics.example.io/v1/series?range=24h')).type in the console — it must print cors. If it still prints opaque, a build artefact somewhere is still passing the mode.

Search the bundle, not just the source. Minified vendor code and copied snippets are where the mode usually survives a refactor:

grep -rn "no-cors" ./src ./dist | grep -v ".map:"

Confirm the server saw both attempts. Because a no-cors request reaches the origin exactly like a cors one, the access log is the fastest way to prove that the browser, not the network, dropped the payload — two 200 lines for the same path, one before the fix and one after, with only the response headers differing.

Security Boundary Note

Do not “solve” this by reflecting whatever arrives in the Origin request header. res.header('Access-Control-Allow-Origin', request.headers.origin) without a membership test grants every page on the internet read access to the API, and if you later add Access-Control-Allow-Credentials: true it grants them the signed-in user’s data as well. Keep the allowlist explicit and compare with exact string equality — the trade-offs between reflection and a fixed wildcard are laid out in Wildcard vs Dynamic Origin Reflection: When to Use Each.

Common Mistakes

Issue Technical impact Mitigation
Treating SyntaxError: Unexpected end of JSON input as a malformed-payload bug Hours spent inspecting a server response that was never delivered to the parser Log res.type first; opaque means the mode, not the payload, is at fault
Using res.ok to detect the problem ok is false for opaque responses and for real 4xx/5xx alike, so retries loop on an unfixable condition Branch on res.type === 'opaque' and raise a configuration error
Adding mode: 'no-cors' to a request that carries Authorization The header is dropped, the API returns an unauthenticated result, and none of it is visible Use the default cors mode and allow the header via Access-Control-Allow-Headers
Setting Access-Control-Allow-Origin but not Access-Control-Expose-Headers The body reads fine while headers.get('X-Series-Version') returns null, which looks like a second CORS bug Name every non-safelisted response header the client reads

FAQ

Why does res.json() throw SyntaxError instead of a CORS error?

Because there is no CORS error to throw. The fetch resolved successfully with an opaque response whose body is null. Calling json() on it reads an empty stream, and an empty string is not valid JSON, so the JSON parser raises SyntaxError: Unexpected end of JSON input. The failure is reported by the parser, not by the CORS check, which is why searching the message never leads to the real cause.

Does removing mode: ‘no-cors’ make the request fail more often?

It makes the same failure visible rather than more frequent. In no-cors mode the response was already unusable; removing the mode turns a silent empty result into an explicit console message naming the missing header. The request itself still leaves the browser in both modes, so nothing new breaks on the server and no traffic pattern changes.

Can I check res.ok to detect an opaque response?

No, because res.ok is false for an opaque response and also false for a genuine 404 or 500, so the two are indistinguishable. Test res.type === 'opaque' instead. That property is set by the response filter and is the only reliable signal that the mode, rather than the endpoint, is the problem.