Skip to main content
Published / updated

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 / await
  • fetch and the mistake everyone makes
  • Status codes and error handling
  • Sending data
  • Loading, error and empty states
  • Cancellation
  • localStorage and sessionStorage
  • 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

RangeMeaningWhose problem
2xxSuccessNobody
3xxRedirectConfiguration
4xxThe request was wrongThe client — usually your code
5xxThe server failedThe backend
CodeMeaningTypical cause
200OK
201CreatedPOST succeeded; check the Location header
204No ContentSuccessful DELETE — do not call .json()
400Bad RequestValidation failed — read the body for field errors
401UnauthorizedNot authenticated — no or expired token
403ForbiddenAuthenticated but not allowed — wrong role
404Not FoundWrong URL, or the record does not exist
409ConflictDuplicate — a roll number already in use
415Unsupported Media TypeMissing Content-Type: application/json
422Unprocessable EntityValid JSON, invalid data
429Too Many RequestsRate limited
500Internal Server ErrorUnhandled 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

StoreLifetimeScopeSize
localStorageUntil clearedOrigin~5–10 MB
sessionStorageUntil the tab closesTab~5–10 MB
CookiesSet by expirySent with every request~4 KB
IndexedDBUntil clearedOriginLarge
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 seeCauseFix
Unexpected token < in JSON at position 0The response was HTML, not JSON — usually a 404 or an error pageCheck the status code first
fetch does not throw on 404fetch rejects only on network failureCheck response.ok
Failed to fetchNetwork, wrong URL, or CORSCheck the Console for the CORS message
CORS error in the browser, works in PostmanCORS is browser-enforced and server-configuredFix it on the server
A field is undefinedCasing mismatch — API returns rollNumber, you read RollNumberMatch the JSON exactly
Results appear in the wrong orderSlow response arrived after a faster later oneCancel 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-Type manually with FormData
  • Sequential await for independent requests
  • await inside a loop where Promise.all would 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 AbortError as a failure
  • Confusing 401 and 403
  • Trying to fix CORS in the frontend
  • Storing an object in localStorage without JSON.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.

  1. Write apiRequest with response.ok, 401 handling, 204 handling and ApiError.
  2. Call an endpoint that returns 404 without checking ok. Record the exact error from .json().
  3. Call a DELETE returning 204 and call .json() on it. Record the error, then handle it.
  4. POST without Content-Type. Confirm 415 in the Network tab, then add the header.
  5. Upload a file with FormData, once with a manual Content-Type and once without. Compare.
  6. Load students and teachers sequentially, then with Promise.all. Time both.
  7. Fetch 10 records in a loop with await, then with Promise.all. Compare.
  8. Build a live search with no debounce and no cancellation. Type quickly and confirm out-of-order results.
  9. Add a 300 ms debounce and an AbortController. Confirm the race is gone and that AbortError is not shown as an error.
  10. Implement all four states — loading, success, empty, error. Force each: valid search, no matches, server stopped.
  11. Remove finally and hide the spinner only on success. Force an error and confirm the spinner never stops.
  12. Store filters in localStorage as an object without JSON.stringify. Read it back and record what you get.
  13. Decode a JWT with atob, read exp, and handle an expired token by clearing it and redirecting.
  14. Call an API on a different port to trigger CORS. Find the OPTIONS request 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.ok and 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

  1. Why does fetch not reject on a 500, and what must you do about it?
  2. What is the difference between 401 and 403?
  3. Why can CORS not be fixed in the frontend?
  4. Why is a token in localStorage a risk, and what is the alternative trade-off?

Next: jQuery and TypeScript