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.
Prerequisite State
- A page on
https://dashboard.example.iocalls an API onhttps://metrics.example.io, so the two are separate origins. - The API responds correctly to a same-origin or server-to-server request; this page fixes the browser-facing contract only.
- You can edit the API’s response headers, either in the application or in the reverse proxy in front of it.
- You have DevTools open with Preserve log enabled, so the failing request row survives a re-render.
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.
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');
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.