How to Read JSON From an API in JavaScript (Fetch)
Photo by Pankaj Patel on Unsplash
Table of Contents
The Pattern You'll Use 90% of the Time
I've debugged this exact flow more times than I can count, usually because someone assumed `fetch` would throw on a bad status code. It won't. A 404 response is, as far as `fetch` is concerned, a perfectly successful network request — it just happens to carry an error status. If you skip the `response.ok` check, you'll merrily try to parse an HTML error page as JSON and get a cryptic "Unexpected token < in JSON" that sends you down the wrong rabbit hole.
This post covers the correct fetch-and-parse pattern, why each step exists, the handful of mistakes that cause 90% of the bugs, how to handle errors properly with try/catch, and a couple of real-world wrinkles like empty responses and non-JSON content types. Everything here is plain browser JavaScript — no libraries needed.
Step by Step: Fetch, Check, Parse
```javascript async function getData(url) { const response = await fetch(url);
if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`); }
const data = await response.json(); return data; } ```
Three things happen, in order:
1. `await fetch(url)` sends the request and resolves with a `Response` object once the headers arrive. Note the body hasn't been read yet — `fetch` resolves as soon as it has the response headers, not the full body.
2. `if (!response.ok)` is the check everyone forgets. `response.ok` is `true` only for status codes in the 200-299 range. A 301, 404, or 500 all give you a perfectly valid `Response` with `ok` set to `false`. If you don't check this, you'll try to parse an error page. If you want a refresher on what each status means, my HTTP status codes explainer breaks down the ranges.
3. `await response.json()` reads the response body stream to completion and parses it as JSON. This returns a promise, which is why you await it. It's the same parsing engine as `JSON.parse`, so it throws a `SyntaxError` if the body isn't valid JSON.
The Promise-chain version does the same thing if you prefer `.then()`:
```javascript fetch(url) .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); }) .then(data => console.log(data)) .catch(error => console.error('Fetch failed:', error)); ```
If the difference between these two styles is fuzzy, my Promises vs async/await breakdown walks through when to use which. The MDN fetch documentation is the authoritative reference for every option.
The Mistakes That Cause Most Bugs
Photo by Tim Gouw on Unsplash
1. Assuming fetch rejects on HTTP errors. It doesn't. `fetch` only rejects on network failures — DNS errors, no connection, CORS blocks. A 500 response resolves successfully. Always check `response.ok` or `response.status`. According to one analysis, malformed responses account for about 25% of fetch-related JSON errors, and most of those are error pages being parsed as JSON because nobody checked the status first.
2. Forgetting to await `.json()`. Writing `const data = response.json()` without `await` gives you a pending Promise, not your data. Then `data.someField` is `undefined` and you waste an hour. The `.json()` method always returns a promise.
3. Calling `.json()` on a non-JSON response. If the server returns HTML (a login redirect, a 404 page, a maintenance screen), `.json()` throws "Unexpected token < in JSON at position 0" — that `<` is the start of `<!DOCTYPE html>`. Check the `Content-Type` header if you're not sure: `response.headers.get('content-type')?.includes('application/json')`.
4. Wrong or relative URL. A surprising share of fetch bugs come from typos or relative paths resolving against the wrong base. One writeup pinned roughly 40% of fetch issues on incorrect or outdated URLs. Log the full URL you're actually hitting before you debug anything else.
5. Reading the body twice. A response body is a stream you can only consume once. Calling `response.json()` after already calling `response.text()` (or vice versa) throws "body stream already read." Pick one.
When the error is genuinely in the JSON itself, don't squint at it in the console. Drop the raw response into the JSON formatter and validator — it pinpoints the exact line and character where the structure breaks, which is far faster than reading a wall of minified JSON by eye.
Handling Errors Like You Mean It
```javascript async function getData(url) { let response; try { response = await fetch(url); } catch (networkError) { // DNS failure, offline, CORS block, etc. throw new Error(`Network error: ${networkError.message}`); }
if (!response.ok) { // Try to read an error body, but don't blow up if it's empty const text = await response.text(); throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`); }
try { return await response.json(); } catch (parseError) { throw new Error(`Invalid JSON in response: ${parseError.message}`); } } ```
Three distinct failure modes, three distinct messages. This matters because "it didn't work" tells you nothing, but "Network error," "HTTP 503," and "Invalid JSON" each point you to a completely different fix.
A few extra wrinkles worth knowing:
Empty responses. A `204 No Content` or a `200` with an empty body will make `.json()` throw, because an empty string isn't valid JSON. Guard it: read the text first, return `null` if it's empty, otherwise `JSON.parse` it.
Timeouts. `fetch` has no built-in timeout. Use `AbortController` to cancel a request that hangs:
```javascript const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 5000); const res = await fetch(url, { signal: controller.signal }); clearTimeout(id); ```
Retries. For flaky endpoints, wrap the call in a small retry loop with exponential backoff. Don't retry on 4xx (those are your fault and won't fix themselves), only on network errors and 5xx. Understanding which codes are retryable is exactly what my HTTP status codes post is for.
Real-World Variations You'll Hit
Photo by Florian Olivo on Unsplash
POST requests with a JSON body. When you're sending data, not just reading it, set the method, headers, and stringify the body:
```javascript const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Sam', role: 'admin' }) }); const data = await res.json(); ```
The `JSON.stringify` on the body is mandatory — `fetch` won't serialize an object for you. If you're shaky on stringify and parse, my JSON.stringify vs JSON.parse explainer covers both directions.
Authenticated requests. Add an `Authorization` header: `headers: { 'Authorization': 'Bearer ' + token }`. If you're debugging a JWT to check its expiry, the JWT decoder shows you the payload without sending the token anywhere.
Query parameters. Build them safely with `URLSearchParams` so special characters get encoded correctly: `const params = new URLSearchParams({ q: 'hello world', page: '2' }); fetch(url + '?' + params)`. This handles the percent-encoding you'd otherwise get wrong by hand.
Streaming large responses. For huge JSON payloads, reading the whole body into memory with `.json()` can be wasteful. The Streams API lets you process chunks as they arrive, though for most apps the simple `.json()` is fine.
Parallel requests. When you need several endpoints at once, `Promise.all` runs them concurrently: `const [a, b] = await Promise.all([fetch(urlA).then(r => r.json()), fetch(urlB).then(r => r.json())]);`. Much faster than awaiting each in sequence.
Handling paginated responses. Many APIs return data in pages with a `next` cursor or page number. The pattern is a loop that keeps fetching until there's no next page:
```javascript async function getAll(baseUrl) { let results = []; let url = baseUrl; while (url) { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const page = await res.json(); results = results.concat(page.items); url = page.next; // null when there are no more pages } return results; } ```
The loop exits cleanly when `page.next` is `null` or `undefined`. I add a safety cap (a max iteration count) in production so a misbehaving API that always returns a `next` can't spin forever — a runaway pagination loop once pinned a browser tab at 100% CPU for me, and the cap would have stopped it cold.
One habit that's saved me real time: when an API returns JSON I've never seen before, I paste a sample response into the JSON formatter and read the pretty-printed structure before writing a single line of parsing code. Seeing the actual shape — which fields are nested, which are arrays, which are nullable — beats guessing from the docs, because docs lie and live responses don't. It also surfaces surprises like a field the docs call an array actually being a single object when there's one result, which is the kind of thing that quietly breaks a `.map()` in production.
Frequently Asked Questions
How do I read JSON from an API in JavaScript?
Use the Fetch API: call fetch(url), await the response, check response.ok to confirm the status is 200-299, then call await response.json() to parse the body into a JavaScript object. The full minimal pattern is: const res = await fetch(url); if (!res.ok) throw new Error(res.status); const data = await res.json(). If the JSON looks broken, validate it with the [ToolsFuel JSON formatter](/tools/json-formatter).
Why doesn't fetch throw an error on a 404 or 500?
Because fetch only rejects on network failures — no connection, DNS errors, CORS blocks. An HTTP error response like 404 or 500 is still a successful network request that returned a valid Response object, just with an error status. You have to check response.ok (true for 200-299) or response.status yourself and throw manually if it's not a success.
Why do I get 'Unexpected token < in JSON at position 0'?
That error means you called .json() on a response that's actually HTML, not JSON. The < is the start of <!DOCTYPE html>. It usually happens when the server returned an error page, a login redirect, or a 404 page instead of JSON data. Check response.ok before parsing, and verify the Content-Type header is application/json.
Do I need to await response.json()?
Yes. The .json() method reads the response body stream and returns a promise, not the parsed data directly. If you forget to await it (or return it from a .then()), you get a pending Promise object instead of your data, and accessing fields on it returns undefined. Always write const data = await response.json().
How do I add a timeout to a fetch request?
Fetch has no built-in timeout, so use an AbortController. Create one, pass its signal to fetch, and call controller.abort() after a setTimeout delay. If the request takes too long, abort() rejects the fetch promise with an AbortError you can catch. Clear the timeout once the response arrives so it doesn't fire late.
How do I send JSON in a POST request with fetch?
Set method to 'POST', add a 'Content-Type': 'application/json' header, and pass JSON.stringify(yourObject) as the body. Fetch won't serialize the object for you, so the stringify step is required. To read the response back, call await response.json() as usual. Start from the [free dev tools](/tools) on ToolsFuel if you need to format or validate the payload first.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools