Skip to main content

localStorage, Cookies, Fetch, AbortController

localStorage, sessionStorage

Web storage objects localStorage and sessionStorage allow saving key/value pairs in the browser.

  • localStorage persists across browser restarts — data stays until explicitly removed.
  • sessionStorage persists within the tab session (including page refreshes) but clears when the tab or window closes.

Both are bound to the origin (domain/protocol/port triplet). Different origins cannot access each other's storage.

Unlike cookies:

  • Web storage objects are not sent to the server with each request. Because of that, we can store much more. Most modern browsers allow at least 5 megabytes of data (or more).
  • The server can't manipulate storage objects via HTTP headers. Everything's done in JavaScript.

Both storage objects provide the same methods and properties:

  • setItem(key, value) – store key/value pair.
  • getItem(key) – get the value by key.
  • removeItem(key) – remove the key with its value.
  • clear() – delete everything.
  • key(index) – get the key on a given position.
  • length – the number of stored items.
// Storing a string
localStorage.setItem('theme', 'dark');

// Storing an object (requires JSON.stringify)
const user = { name: 'Alice', loggedIn: true };
localStorage.setItem('user', JSON.stringify(user));

// Retrieving and parsing the data
const savedTheme = localStorage.getItem('theme'); // 'dark'
const savedUser = JSON.parse(localStorage.getItem('user')); // { name: 'Alice', loggedIn: true }

console.log(savedTheme);
console.log(savedUser.name); // 'Alice'

// Removing an item
localStorage.removeItem('theme');

// Clear all data
localStorage.clear();

Security note: localStorage is accessible to any JavaScript running on the page. If your site is vulnerable to XSS, an attacker can read everything in localStorage. Never store highly sensitive data (passwords, unencrypted tokens) here without understanding the risk. In practice, many apps store JWTs in localStorage for convenience, but HttpOnly cookies are more secure.

Cookies

Cookies are the oldest web storage mechanism and the only one that's automatically sent with HTTP requests.

Cookies are small strings of data stored directly in the browser. They are usually set by a web server using the response Set-Cookie HTTP header. Then, the browser automatically adds them to (almost) every request to the same domain using the Cookie HTTP header.

alert( document.cookie ); // cookie1=value1; cookie2=value2;...

Split by ; to get separate cookies.

Writing to document.cookie

We can write to document.cookie. But it's not a data property, it's an accessor (getter/setter). An assignment to it is treated specially.

A write operation to document.cookie updates only the cookie mentioned in it and doesn't touch other cookies.

document.cookie = "user=John"; // update only cookie named 'user'
alert(document.cookie); // show all cookies

Cookies have many optional but important attributes:

document.cookie = "username=John Doe; expires=Thu, 18 Dec 2099 12:00:00 UTC; path=/; SameSite=Lax; Secure";
  • ;path=path — URL path the cookie is accessible on. Default: current path.
  • ;domain=domain — Domain the cookie is accessible on. By default, only the exact domain that set it.
  • ;expires=date-in-UTCString-format — When the cookie expires. Without this, cookie is deleted when browser closes (session cookie).
  • ;max-age=seconds — Alternative to expires. Number of seconds until the cookie expires.
  • ;secure — Cookie is only sent over HTTPS. Important for any sensitive data.
  • ;samesite=lax/strict/none — CSRF protection. Controls whether cookies are sent with cross-site requests.
    • Strict — never sent with cross-site requests
    • Lax (browser default) — sent with top-level navigations but not with third-party requests
    • None — sent with all requests (requires Secure flag)
  • HttpOnly — Cookie is NOT accessible via document.cookie. Can only be set by the server via Set-Cookie header. This is the primary defense against XSS attacks stealing session cookies.

https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie

Storage comparison

FeaturelocalStoragesessionStorageCookiesIndexedDB
Size limit~5-10 MB~5-10 MB~4 KBLarge (quota-based)
PersistenceUntil clearedTab sessionUntil expiryUntil cleared
Sent with requestsNoNoYes, every requestNo
APISimple key-valueSimple key-valueString-basedStructured, transactional
Best forUser preferences, cached dataTemporary tab stateAuthentication, server stateLarge data, offline apps

For large structured data (offline apps, PWAs), see IndexedDB. Covered after TypeScript since the idb library uses TypeScript interfaces.

Fetch API

The Fetch API is the modern replacement for XMLHttpRequest. It provides a promise-based interface for making HTTP requests.

With the Fetch API, you make a request by calling fetch(), which is available as a global function in both window and worker contexts. You pass it a URL string (or a Request object), along with an optional argument to configure the request.

The fetch() function returns a Promise which is fulfilled with a Response object representing the server's response. You can then check the request status and extract the body of the response in various formats, including text and JSON, by calling the appropriate method on the response.

Important: fetch() only rejects on network failures (DNS errors, offline, etc.). HTTP error responses (404, 500) still resolve normally — you must check response.ok or response.status yourself. This is a common gotcha.

async function getData() {
const url = "https://example.org/products.json";
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}

const result = await response.json();
console.log(result);
} catch (error) {
console.error(error.message);
}
}

Request options

The first parameter of fetch() is the URL (or a Request object). The second parameter is an optional options object.

Options include:

  • method — GET, POST, DELETE, PUT, etc.
  • headers — Custom headers for the request (Content-Type, Authorization, etc.)
  • body — Request body for methods like POST or PUT. Can be JSON string, FormData, plain text, etc.
  • mode — Controls CORS behavior.
  • cache — How the browser handles caching.
  • credentials — Whether to send cookies (see below).
  • signal — An AbortSignal to cancel the request.

Sending cookies and authentication

By default, fetch() sends cookies only to same-origin requests. The credentials option controls this:

// Default — send cookies to same-origin only
fetch(url, { credentials: 'same-origin' });

// Send cookies to all origins (requires CORS on server)
fetch(url, { credentials: 'include' });

// Never send cookies
fetch(url, { credentials: 'omit' });

Cross-Origin Requests and CORS

CORS (Cross-Origin Resource Sharing) is a security mechanism that restricts which resources a web page can request from a different domain. The server must include the appropriate headers (e.g., Access-Control-Allow-Origin) to allow your origin to access the resource.

Response

Once you receive the response, you can process it in various ways. Common methods available on the response object include:

  • response.json() — parses the response body as JSON and returns a promise that resolves to a JavaScript object.
  • response.text() — returns a promise that resolves to the response body as plain text.
  • response.blob() — binary data (images, files).
  • response.arrayBuffer() — raw binary buffer.

POST with JSON (using async/await)

async function createPost() {
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "foo",
body: "bar",
userId: 1,
}),
});

const result = await response.json();
console.log(result);
}

POST with JSON (using .then() chains)

const headers = {
"Content-Type": "application/json",
};

const body = JSON.stringify({
title: "foo",
body: "bar",
userId: 1,
});

const requestOptions = {
method: "POST",
headers,
body,
};

fetch("https://jsonplaceholder.typicode.com/posts", requestOptions)
.then((response) => response.json())
.then((result) => console.log(result))
.catch((error) => console.log("error", error));

Using FormData

const body = new FormData();
body.append("title", "foo");
body.append("body", "bar");
body.append("userId", 1);

const requestOptions = {
method: "POST",
body,
};

fetch("https://jsonplaceholder.typicode.com/posts", requestOptions)
.then((response) => response.json())
.then((result) => console.log(result))
.catch((error) => console.log("error", error));

Note: When using FormData, do NOT set the Content-Type header manually. The browser automatically sets it to multipart/form-data with the correct boundary string.

AbortController and AbortSignal

Sometimes you need to cancel in-flight requests — timeouts, user navigation, or component unmounts. AbortController provides a way to abort fetch requests and other async operations.

An instance of the AbortController class exposes the abort() method and the signal property. Invoking abort() emits the abort event to notify the abortable API watching the controller about the cancellation.

Ordinarily, we expect the result of an asynchronous operation to succeed or fail. However, the process can also take more time than anticipated, or you may no longer need the results when you receive them.

Setting timeouts with AbortController

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

fetch("https://jsonplaceholder.typicode.com/posts", {
signal: controller.signal,
})
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error(error))
.finally(() => clearTimeout(timeout));

Using AbortSignal.timeout()

A simpler built-in way to set a timeout:

const signal = AbortSignal.timeout(200);
const url = "https://jsonplaceholder.typicode.com/todos/1";

const fetchTodo = async () => {
try {
const response = await fetch(url, { signal });
const todo = await response.json();
console.log(todo);
} catch (error) {
if (error.name === "AbortError") {
console.log("Operation timed out");
} else {
console.error(error);
}
}
};

fetchTodo();

Using multiple abort signals — AbortSignal.any()

Combine multiple abort reasons (user cancellation + timeout) into one signal:

// Create two separate controllers for different concerns
const userController = new AbortController();
const timeoutController = new AbortController();

// Set up a timeout that will abort after 5 seconds
setTimeout(() => timeoutController.abort(), 5000);

// Register an event listener that can be aborted (removed) by either signal
document.addEventListener('click', handleUserClick, {
signal: AbortSignal.any([userController.signal, timeoutController.signal])
});

Defence Preparation

Be prepared to explain topics like these:

  1. What is the difference between localStorage, sessionStorage, and cookies? When would you use each?localStorage persists data with no expiration until explicitly cleared, scoped to the origin. sessionStorage persists only for the duration of the browser tab/session. Cookies are sent with every HTTP request to the server and can have expiration dates. Use localStorage for client-side preferences or cached data, sessionStorage for temporary per-tab state, and cookies for data that the server needs to see (like session IDs or auth tokens with HttpOnly).
  2. What do the HttpOnly, Secure, and SameSite cookie attributes do? Why are they important for security?HttpOnly prevents JavaScript from reading the cookie via document.cookie, protecting against XSS-based token theft. Secure ensures the cookie is only sent over HTTPS. SameSite controls whether the cookie is sent with cross-origin requests — Strict blocks all cross-site sending, Lax allows it on top-level navigations, and None (requires Secure) allows it always. Together, these attributes defend against XSS and CSRF attacks.
  3. Why does fetch() not reject on HTTP error statuses like 404 or 500? How do you handle errors correctly?fetch() only rejects on network failures (DNS errors, no connection). A 404 or 500 response is still a valid HTTP response from the server's perspective, so fetch resolves normally. You must check response.ok (which is true for status 200-299) or response.status and throw or handle the error explicitly. This is a common gotcha compared to libraries like Axios, which reject on non-2xx statuses by default.
  4. What is CORS, and why does the browser enforce it? — CORS (Cross-Origin Resource Sharing) is a security mechanism that prevents JavaScript on one origin (e.g., localhost:5173) from making requests to a different origin (e.g., api.example.com) unless the server explicitly allows it via Access-Control-Allow-Origin headers. Without CORS, malicious websites could make authenticated requests to your bank API using your cookies. The browser blocks the response — the request still reaches the server, but JavaScript cannot read the result.
  5. What does the credentials option in fetch() control? — The credentials option determines whether cookies and authentication headers are sent with the request. same-origin (default) sends them only to the same origin. include sends them to any origin, which is required for authenticated cross-origin API calls. omit never sends credentials. When using include, the server must also respond with Access-Control-Allow-Credentials: true and cannot use * for Access-Control-Allow-Origin.
  6. How does AbortController work for canceling fetch requests? — Create an AbortController instance, pass its signal property to the fetch() options, and call controller.abort() when you want to cancel. This rejects the fetch promise with an AbortError. It is useful for canceling requests when a user navigates away, types a new search query (cancel the old one), or when you want to enforce a timeout. AbortSignal.timeout(ms) provides a shorthand for timeout-based cancellation.
  7. What is the difference between sending data as JSON vs FormData in a fetch POST request? — With JSON, you set Content-Type: application/json and serialize the data with JSON.stringify(). The server receives a JSON string and must parse it. With FormData, the request is sent as multipart/form-data — you do not set the Content-Type header manually (the browser adds it with the boundary). FormData is required for file uploads and can be constructed directly from a <form> element. JSON is more common for pure data APIs.
  8. Why is storing sensitive data like JWTs in localStorage considered a security risk?localStorage is accessible to any JavaScript running on the same origin, including scripts injected via XSS vulnerabilities. If an attacker can execute JavaScript on your page, they can read everything in localStorage and steal tokens. HttpOnly cookies are a safer alternative for tokens because JavaScript cannot access them at all — they are sent automatically by the browser with each request.