JSON, Fetch and Async JavaScript
Before you start
You need: the DOM and events (Article 09). An API to call helps — Track 10 builds one, but any public API works.
Time: about 50 minutes, plus the practice.
Learning objective
Call a REST API correctly, including every failure path, and know what belongs in browser storage and what does not.
Topics
- JSON and its limits
- Promises
async/awaitfetchand the mistake everyone makes- Status codes and error handling
- Sending data
- Loading, error and empty states
- Cancellation
localStorageandsessionStorage- Tokens and CORS
JSON
const student = { id: 1, name: 'Ravi Kumar', marks: 87, isActive: true, address: null };
const json = JSON.stringify(student);
const parsed = JSON.parse(json);
JSON.stringify(student, null, 2); // pretty printed
JSON.stringify(student, ['id', 'name']); // only these keys
JSON supports strings, numbers, booleans, null, arrays and objects. It does not support:
JSON.stringify({ date: new Date() }); // becomes an ISO string
JSON.stringify({ value: undefined }); // key is dropped entirely
JSON.stringify({ fn: () => {} }); // dropped
JSON.stringify({ big: 10n }); // TypeError
JSON.stringify({ set: new Set([1, 2]) }); // becomes {}
JSON.stringify({ n: NaN }); // becomes null
Dates round-trip as strings. JSON.parse gives you "2024-06-15T00:00:00Z", not a Date. Convert explicitly:
const student = JSON.parse(json, (key, value) => {
if (key === 'dateOfBirth' && typeof value === 'string') {
return new Date(value);
}
return value;
});
JSON.parse throws on malformed input, so guard it:
function safeParse(text, fallback = null) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
Promises
A promise is pending, then either fulfilled or rejected.
const promise = fetch('/api/students');
promise
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error))
.finally(() => hideSpinner());
Promise.all([a, b, c]); // all, or reject on the first failure
Promise.allSettled([a, b, c]); // all, with per-promise status
Promise.race([a, b]); // the first to settle either way
Promise.any([a, b]); // the first to fulfil
// Parallel — total time is the slowest request
const [students, teachers] = await Promise.all([
fetchStudents(schoolId),
fetchTeachers(schoolId)
]);
// Sequential — total time is the sum
const students = await fetchStudents(schoolId);
const teachers = await fetchTeachers(schoolId);
Independent requests belong in Promise.all. Awaiting them one after another for no reason is the most common async performance mistake.
Promise.allSettled when a partial result is acceptable:
const results = await Promise.allSettled([fetchStudents(), fetchFees()]);
for (const result of results) {
if (result.status === 'fulfilled') {
render(result.value);
} else {
console.error(result.reason);
}
}
async / await
async function loadStudents(schoolId) {
try {
const response = await fetch(`/api/students?schoolId=${schoolId}`);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to load students', error);
throw error;
}
}
An async function always returns a promise. await pauses until it settles, and a rejection becomes a thrown exception — which is why try/catch works.
// Sequential when each depends on the previous — correct
const student = await fetchStudent(publicId);
const fees = await fetchFees(student.id);
// Sequential when they are independent — wasteful
const students = await fetchStudents();
const teachers = await fetchTeachers();
// Parallel — correct for independent work
const [students, teachers] = await Promise.all([fetchStudents(), fetchTeachers()]);
A loop with await inside is sequential:
// N requests, one after another
for (const id of ids) {
results.push(await fetchStudent(id));
}
// All at once
const results = await Promise.all(ids.map(id => fetchStudent(id)));
Use the sequential form deliberately when order matters or when you must not overwhelm the server; otherwise parallelise.
await at the top level works only in a module. In a plain script, wrap it:
(async () => {
const students = await loadStudents(1);
})();
fetch
const response = await fetch('/api/students');
// The mistake almost everyone makes first
const data = await fetch('/api/students').then(r => r.json());
fetch does not reject on 404 or 500. It rejects only on a network-level failure — DNS, no connection, or a CORS block. A 404 or a 500 resolves normally, and calling .json() on an HTML error page throws Unexpected token < in JSON at position 0 — which sends people looking for a JSON bug that does not exist.
const response = await fetch('/api/students');
if (!response.ok) { // true only for 200–299
throw new ApiError(response.status, await response.text());
}
const data = await response.json();
Axios throws on non-2xx by default, which is one reason teams prefer it. With fetch, response.ok must be checked on every call.
A reusable client
class ApiError extends Error {
constructor(status, body) {
super(`API request failed with status ${status}`);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
async function apiRequest(path, options = {}) {
const token = getToken();
const response = await fetch(path, {
...options,
headers: {
'Accept': 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers
}
});
if (response.status === 401) {
clearToken();
window.location.href = '/login.html';
throw new ApiError(401, 'Session expired');
}
if (!response.ok) {
let body;
try {
body = await response.json();
} catch {
body = await response.text();
}
throw new ApiError(response.status, body);
}
if (response.status === 204) {
return null;
}
return await response.json();
}
Every failure path is handled once: 401 redirects to login, other errors carry the status and body, and 204 returns null rather than throwing on an empty body.
Calling .json() on a 204 throws — the body is empty, and that is a valid response for a successful DELETE.
Status codes
| Range | Meaning | Whose problem |
|---|---|---|
| 2xx | Success | Nobody |
| 3xx | Redirect | Configuration |
| 4xx | The request was wrong | The client — usually your code |
| 5xx | The server failed | The backend |
| Code | Meaning | Typical cause |
|---|---|---|
| 200 | OK | |
| 201 | Created | POST succeeded; check the Location header |
| 204 | No Content | Successful DELETE — do not call .json() |
| 400 | Bad Request | Validation failed — read the body for field errors |
| 401 | Unauthorized | Not authenticated — no or expired token |
| 403 | Forbidden | Authenticated but not allowed — wrong role |
| 404 | Not Found | Wrong URL, or the record does not exist |
| 409 | Conflict | Duplicate — a roll number already in use |
| 415 | Unsupported Media Type | Missing Content-Type: application/json |
| 422 | Unprocessable Entity | Valid JSON, invalid data |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Server Error | Unhandled exception on the server |
401 versus 403 is the distinction interviewers ask about. 401 means the server does not know who you are — log in again. 403 means it knows exactly who you are and you are not permitted — logging in again changes nothing.
ASP.NET Core returns a field-level errors object on 400:
catch (error) {
if (error instanceof ApiError && error.status === 400 && error.body?.errors) {
for (const [field, messages] of Object.entries(error.body.errors)) {
showFieldError(field, messages[0]);
}
return;
}
showGeneralError('Something went wrong. Please try again.');
}
Sending data
const created = await apiRequest('/api/students', {
method: 'POST',
body: JSON.stringify({
name: 'Sneha Patel',
rollNumber: 'NCA-2024-0044',
className: '9th',
section: 'A'
})
});
Content-Type: application/json is required. Without it fetch sends text/plain, and an ASP.NET Core [FromBody] binder rejects the request with 415 before your controller runs. The client wrapper above adds it automatically whenever there is a body.
// File upload — do NOT set Content-Type
const formData = new FormData();
formData.append('photo', fileInput.files[0]);
await fetch('/api/students/photo', { method: 'POST', body: formData });
With FormData, the browser sets multipart/form-data including the boundary. Setting Content-Type yourself omits the boundary and the server cannot parse the body.
const params = new URLSearchParams({ schoolId: 1, term: '10th & A', page: 1 });
await apiRequest(`/api/students?${params}`);
URLSearchParams encodes values correctly. Concatenating term directly breaks on &, # and spaces.
Loading, error and empty states
Every request has four outcomes, and most student projects handle one.
async function loadAndRenderStudents(schoolId, term) {
showLoading();
try {
const result = await apiRequest(
`/api/students?${new URLSearchParams({ schoolId, term })}`);
if (result.items.length === 0) {
showEmpty(term);
return;
}
renderStudents(result.items);
} catch (error) {
if (error.name === 'AbortError') {
return; // superseded, not a failure
}
showError(error instanceof ApiError && error.status >= 500
? 'The server is not responding. Please try again shortly.'
: 'Could not load students.');
} finally {
hideLoading();
}
}
finally hides the spinner on every path — success, failure and cancellation. Hiding it only in the success branch leaves a permanent spinner after an error, which users report as "the page froze".
Distinguish the empty state from the error state. "No students match your search" and "Could not load students" are different messages and different next actions.
Cancellation
let controller = null;
async function search(term) {
controller?.abort(); // cancel the previous request
controller = new AbortController();
try {
const result = await apiRequest(
`/api/students?term=${encodeURIComponent(term)}`,
{ signal: controller.signal });
renderStudents(result.items);
} catch (error) {
if (error.name === 'AbortError') {
return;
}
showError('Search failed.');
}
}
searchInput.addEventListener('input', debounce(event => search(event.target.value), 300));
Without cancellation, typing "Ravi" fires four requests and they can arrive out of order — so the list ends up showing results for "Rav". Aborting the previous request removes the race entirely.
Combine with a timeout:
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
Browser storage
| Store | Lifetime | Scope | Size |
|---|---|---|---|
localStorage | Until cleared | Origin | ~5–10 MB |
sessionStorage | Until the tab closes | Tab | ~5–10 MB |
| Cookies | Set by expiry | Sent with every request | ~4 KB |
| IndexedDB | Until cleared | Origin | Large |
localStorage.setItem('preferredClass', '10th');
localStorage.getItem('preferredClass'); // null if absent
localStorage.removeItem('preferredClass');
localStorage.setItem('filters', JSON.stringify({ className: '10th', section: 'A' }));
const filters = JSON.parse(localStorage.getItem('filters') ?? '{}');
Storage holds strings only — everything else needs JSON.stringify. And every access can throw:
function readSetting(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
return fallback; // private mode, quota exceeded, or corrupt JSON
}
}
Safari in private mode and a full quota both throw on setItem. Wrap every access.
Tokens
localStorage.setItem('authToken', token);
A token in localStorage is readable by any JavaScript on the page — including a compromised third-party library. That is the standard XSS token-theft path.
An HttpOnly cookie cannot be read by JavaScript at all, which removes that risk but requires CSRF protection instead. Both approaches are used in production; knowing the trade-off is the interview answer.
Whichever you choose, check expiry:
function isTokenExpired(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 < Date.now();
} catch {
return true;
}
}
A token that expired hours ago is still in storage, and every call returns 401. The application must clear it and redirect rather than looping.
Never decode a token to make an authorisation decision. The client can read the claims, but only the server's signature check makes them trustworthy. Use them to show or hide UI; the server still enforces.
CORS
Access to fetch at 'https://api.nexcoding.in/api/students'
from origin 'https://portal.nexcoding.in' has been blocked by CORS policy.
The browser's same-origin policy stops JavaScript reading a response from a different origin — scheme, host and port — unless the server allows it.
The request usually reached the server and succeeded. The server returned 200; the browser then refused to hand the response to your code because Access-Control-Allow-Origin was missing. That is why the same call works in Postman, which is not a browser and does not enforce CORS.
There is no client-side fix. No fetch option, no header you can add, no framework setting. The permission must come from the server's response:
// Program.cs — the fix is here, not in JavaScript
builder.Services.AddCors(options =>
{
options.AddPolicy("SchoolPortal", policy =>
{
policy.WithOrigins("https://portal.nexcoding.in")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
app.UseCors("SchoolPortal");
For anything beyond a simple GET the browser sends an OPTIONS preflight first. An OPTIONS request returning 404 or 405 in the Network tab means CORS middleware is not wired up — that is the real failure, and the console message is the symptom.
Do not use AllowAnyOrigin() to make it go away: it opens the API to every site, and it is incompatible with credentials anyway.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
Unexpected token < in JSON at position 0 | The response was HTML, not JSON — usually a 404 or an error page | Check the status code first |
fetch does not throw on 404 | fetch rejects only on network failure | Check response.ok |
Failed to fetch | Network, wrong URL, or CORS | Check the Console for the CORS message |
| CORS error in the browser, works in Postman | CORS is browser-enforced and server-configured | Fix it on the server |
A field is undefined | Casing mismatch — API returns rollNumber, you read RollNumber | Match the JSON exactly |
| Results appear in the wrong order | Slow response arrived after a faster later one | Cancel or ignore stale requests |
fetch treats a 404 as a successful request. You must check response.ok yourself; otherwise you call .json() on an error page.
Common mistakes
- Not checking
response.ok - Calling
.json()on a 204 - Missing
Content-Type: application/json, causing 415 - Setting
Content-Typemanually withFormData - Sequential
awaitfor independent requests awaitinside a loop wherePromise.allwould do- No loading, error or empty state
- Hiding the spinner only on success
- No debounce or cancellation on live search, causing out-of-order results
- Treating
AbortErroras a failure - Confusing 401 and 403
- Trying to fix CORS in the frontend
- Storing an object in
localStoragewithoutJSON.stringify - No try/catch around storage access
- Never checking token expiry
- Trusting decoded token claims for authorisation
Practice
The course exercises are render API data and the typed API client assignment.
- Write
apiRequestwithresponse.ok, 401 handling, 204 handling andApiError. - Call an endpoint that returns 404 without checking
ok. Record the exact error from.json(). - Call a DELETE returning 204 and call
.json()on it. Record the error, then handle it. - POST without
Content-Type. Confirm 415 in the Network tab, then add the header. - Upload a file with
FormData, once with a manualContent-Typeand once without. Compare. - Load students and teachers sequentially, then with
Promise.all. Time both. - Fetch 10 records in a loop with
await, then withPromise.all. Compare. - Build a live search with no debounce and no cancellation. Type quickly and confirm out-of-order results.
- Add a 300 ms debounce and an
AbortController. Confirm the race is gone and thatAbortErroris not shown as an error. - Implement all four states — loading, success, empty, error. Force each: valid search, no matches, server stopped.
- Remove
finallyand hide the spinner only on success. Force an error and confirm the spinner never stops. - Store filters in
localStorageas an object withoutJSON.stringify. Read it back and record what you get. - Decode a JWT with
atob, readexp, and handle an expired token by clearing it and redirecting. - Call an API on a different port to trigger CORS. Find the
OPTIONSrequest in the Network tab, then fix it server-side.
Then run the course debugging exercise — trace a failed network request. For each of 400, 401, 403, 404, 415 and 500, record what the Network tab shows and what the user should be told.
You can now
- Call a REST API and handle every outcome
- Check
response.okand each status code separately - Recognise a CORS error and say where it is fixed
- Match JSON field casing exactly
- Avoid a race between two in-flight requests
Review questions
- Why does
fetchnot reject on a 500, and what must you do about it? - What is the difference between 401 and 403?
- Why can CORS not be fixed in the frontend?
- Why is a token in
localStoragea risk, and what is the alternative trade-off?
Next: jQuery and TypeScript