Hello. In the previous lesson, you traced how JavaScript chooses this in methods and callbacks. Now we shift from code running entirely in the browser to code that communicates with a server.
You already know promises, async/await, try/catch, and DOM updates. This lesson combines them into a core full-stack workflow: request data from a JSON REST endpoint, verify that the server response is usable, convert its JSON body into JavaScript values, and render those values in the page.
Plan for about 40 minutes, including the implementation checkpoint.
The browser–API contract
An API endpoint is a URL through which a client can request or modify a resource. For example, a frontend might request a products collection from an endpoint such as:
/api/products
When the browser requests a JSON endpoint, there are three distinct things to keep apart:
- The request: what the browser sends, including the URL and HTTP method.
- The response metadata: status code, headers, and whether the response is considered successful.
- The response body: the actual content sent by the server, such as JSON.
fetch() starts the request:
const response = await fetch("/api/products");
A URL alone produces a GET request by default. GET is used to retrieve data; this lesson focuses on that read operation.
The variable response is not your products yet. It is a Response object containing metadata and a body stream. To obtain JSON data, you must read and parse the body:
const products = await response.json();
There are therefore two asynchronous waits:
const response = await fetch("/api/products");
const products = await response.json();
The first waits until the browser receives the response status and headers. The second waits for the body to be read and parsed as JSON.
This does not freeze the browser while the request travels across the network. await pauses only the current async function; the event loop can still handle rendering, clicks, and other work.
Watch the focused introduction below. It will reinforce the distinction between fetch(), the Response, and parsed JSON data.
Watch “Learn Fetch API In 6 Minutes” by Web Dev Simplified for a concise visual walkthrough of a GET request, JSON parsing, and the important error-status trap.
From the basic request, focus on why fetch() initially gives a Promise rather than the requested data. Then watch the response body to see why response.json() is another asynchronous operation. Finish with the status trap: a 404 response does not automatically enter catch.
JSON becomes ordinary JavaScript data
JSON is text used to exchange structured data between systems. A server might send this body:
{
"products": [
{
"id": "p_101",
"name": "Mechanical Keyboard",
"price": 4999
}
]
}
Over the network, that is text. After this line runs:
const data = await response.json();
data is a normal JavaScript object:
console.log(data.products[0].name);
// Mechanical Keyboard
So after parsing, you use properties, arrays, destructuring, map(), and other JavaScript tools exactly as you would with locally created data.
There are two related operations worth distinguishing:
| Operation | Purpose | Typical place |
|---|---|---|
response.json() | Reads a JSON HTTP response and parses it | Receiving API data |
JSON.parse(text) | Parses a JSON string you already have | Working with raw text |
JSON.stringify(value) | Converts a JavaScript value to JSON text | Sending JSON later in request bodies |
For a GET request, you normally do not use JSON.stringify(), because GET requests retrieve data and do not carry a request body.
Read MDN’s explanation alongside the code you will build next. It is a reliable reference for the response lifecycle and the difference between network failures and HTTP error responses.
Using the Fetch API - MDN Web Docs
Read MDN Web Docs’ “Using the Fetch API” to establish a precise model of what fetch() returns, when it resolves, and why status checking belongs in application code.
At the top of the article, read the Fetch overview. Then read the minimal code example immediately before the “Making a request” section and its explanation below it. Finally, in “Handling the response,” read the subsections “Checking response status,” “Checking headers,” and “Reading the response body.” Focus on the separate roles of response.ok, response.headers, and response.json().
A reliable JSON GET request
A minimal request is useful for experiments:
const response = await fetch("/api/products");
const products = await response.json();
Production code needs one more layer of thought: a completed HTTP request is not necessarily a successful application request.
For example, if your API returns a 404 Not Found response, the browser successfully contacted the server and received an answer. Therefore, fetch() usually resolves with a Response object.
fetch() rejects mainly when the browser cannot complete the request at all, such as a network failure, an invalid URL, or certain browser-level restrictions. It does not reject merely because the server sends a 404 or 500 status.
Use response.ok to separate successful HTTP responses from unsuccessful ones:
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const products = await response.json();
response.ok is true for status codes in the through range. It is false for statuses such as:
401: the client is not authenticated.403: the client is authenticated but not allowed.404: the requested resource does not exist.500: the server encountered an unexpected error.
Throwing inside the try block deliberately turns an unsuccessful HTTP response into an error your catch block can handle.
A reusable pattern
This is a solid baseline for reading JSON from an endpoint:
async function fetchJson(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
throw new TypeError("Expected a JSON response.");
}
return await response.json();
} catch (error) {
console.error("Could not fetch JSON:", error);
throw error;
}
}
Its responsibilities are deliberately narrow:
fetch(url)starts the GET request.response.okverifies the HTTP result before treating it as normal data.content-typechecks that the server claims to have sent JSON. A valid JSON content type can include extra information such asapplication/json; charset=utf-8, which is whyincludes()is appropriate.response.json()reads and parses the body.throw errorlets the caller decide how the interface should respond.
The function does not know whether the JSON contains products, users, or tasks. That makes it useful across a frontend codebase.
One constraint to remember: a response body is consumed when you read it. This will not work:
const data = await response.json();
const text = await response.text();
Choose the body reader that matches the response contract. For a JSON API, that is normally response.json().
From an endpoint to visible content
Fetching data is only valuable if the application uses it. The next example retrieves a JSON document, uses destructuring to extract its fields, and renders a small view without injecting raw HTML.
Assume the page contains:
<main id="app"></main>
The MDN learning endpoint used below returns a JSON object with a squad name, hometown, and members array.
const endpoint =
"https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json";
const app = document.querySelector("#app");
async function loadSquad() {
app.textContent = "Loading squad...";
try {
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
throw new TypeError("The server did not return JSON.");
}
const squad = await response.json();
renderSquad(squad);
} catch (error) {
console.error(error);
app.textContent = `Could not load squad data: ${error.message}`;
}
}
function renderSquad({ squadName, homeTown, members }) {
const fragment = document.createDocumentFragment();
const heading = document.createElement("h1");
heading.textContent = squadName;
const location = document.createElement("p");
location.textContent = `Based in ${homeTown}`;
const list = document.createElement("ul");
for (const { name, powers } of members) {
const item = document.createElement("li");
item.textContent = `${name}: ${powers.join(", ")}`;
list.append(item);
}
fragment.append(heading, location, list);
app.replaceChildren(fragment);
}
loadSquad();
Notice how this combines earlier JavaScript concepts with Fetch:
async function loadSquad()enablesawait.try/catchhandles failed network operations, invalid JSON, and the manual error thrown for unsuccessful HTTP statuses.const { squadName, homeTown, members } = ...uses object destructuring.for (const { name, powers } of members)destructures each member object while iterating.textContentinserts server-supplied strings as text, rather than treating them as HTML.replaceChildren()replaces the loading text with the completed interface.
The API’s data shape is part of the contract. Do not guess that every endpoint returns an array. These are all plausible response shapes:
const products = await response.json();
// products might be an array
const { products } = await response.json();
// the endpoint may return an object containing a products array
const { data, pagination } = await response.json();
// the endpoint may return data plus metadata
Before writing rendering logic, inspect the parsed value:
console.log(await response.json());
In a real project, read the API documentation and verify the response in browser tools. A mismatch between expected and actual JSON shape is one of the most common causes of errors such as:
Cannot read properties of undefined
Failures have different meanings
A single catch block can display a user-facing failure message, but when debugging, identify the category of failure.
| What happened | Example | What fetch() does | Your code should do |
|---|---|---|---|
| Network-level failure | Device offline, DNS failure | Rejects | Handle it in catch |
| HTTP failure | API returns 404 or 500 | Resolves with ok === false | Check response.ok, then throw or handle explicitly |
| Wrong content type | Server returns an HTML error page | Usually resolves | Inspect headers and reject unexpected formats |
| Invalid JSON | Body is malformed JSON | response.json() rejects | Handle it in catch |
| Unexpected data shape | members is missing | Parsing succeeds | Validate or inspect the returned data before rendering |
This distinction matters when your React frontend later talks to your Express API. A frontend can only make good decisions if the API is consistent about:
- returning an appropriate HTTP status,
- sending the correct
Content-Type, - and using predictable JSON shapes.

The screenshot’s structure is worth noticing: the request logic is separated from the DOM-rendering functions. Keep that separation as your applications grow. A request function should retrieve and validate data; a rendering function should decide how that data appears.
The next lesson will focus on inspecting the actual request and response in browser developer tools, which makes these failure categories much easier to diagnose.
Implementation checkpoint
Create a small HTML page with an element whose ID is app, then run the loadSquad() example in a JavaScript module or a normal script file.
While testing, make these deliberate changes one at a time:
- Log the
Responseobject before callingresponse.json(). - Log the parsed
squadobject before callingrenderSquad(squad). - Change the endpoint path slightly so it returns a 404, then confirm that your
response.okguard produces the error UI. - Temporarily replace
membersinrenderSquad()with a property that does not exist, observe the failure, and restore it. This demonstrates that a successful fetch and successful JSON parse do not guarantee the shape your UI expects.
Avoid solving errors by removing the status check or try/catch. Those checks are the parts that make the request code dependable.
Key takeaways
fetch(url)starts an HTTP request and returns a Promise for aResponse, not for the parsed data itself.- A JSON request normally needs two awaits: one for
fetch()and one forresponse.json(). response.json()parses JSON response text into ordinary JavaScript values.fetch()does not reject simply because the server returns 404 or 500. Checkresponse.okexplicitly.try/catchhandles network failures, manually thrown HTTP-status errors, and JSON parsing errors.- Inspect the actual data shape before assuming whether an endpoint returns an array, an object, or a wrapped payload.
- Keep data retrieval and DOM rendering as separate responsibilities.
Next, you will inspect these requests directly in browser developer tools: URL, method, status, headers, response body, and timing.
Can't find a good explanation? Sign up and we'll make it for you
Sign up