JavaScript in the browser
Every web screen Mangrove Market ships, from mangrove-dashboard to the back-office pages its buyers use, runs on the same few browser ideas: a tree of elements, events that travel through it, fetch to talk to APIs, and a pipeline that turns changes into pixels. Learn them once and React, and even React Native, make much more sense.
By the end you can
- Explain the DOM and change a page safely from JavaScript
- Use event bubbling for event delegation: one listener for a whole table
- Call an API with
fetch, handle every error state, and explain CORS and the preflight in plain words - Choose between localStorage, sessionStorage and cookies
- Say why too many DOM changes are slow, and how React and React Native build on these ideas
The everyday picture
Think of a Mangrove Market store's front window. The HTML is what is in the window: the shelves, the price boards, the festival offer poster. The CSS is the decoration: colours, lighting, where each thing stands. And JavaScript is the staff inside, reacting to customers: someone points at a product, and a staff member brings it closer, changes the price board, or checks the backstore.
The staff can only work with what is in the window right now. If they rearrange the whole window every time one customer asks one question, everyone outside waits. Good staff change only the one board that needs changing. That is most of browser performance in one sentence.
The DOM: the page as a tree of objects
When the browser reads your HTML, it builds the DOM, a tree of objects called nodes. <table> contains <tbody>, which contains rows, which contain cells. JavaScript never edits the HTML file; it changes this live tree, and the browser redraws what changed.
You will use four kinds of operations again and again:
- Find:
document.querySelector('#rows')returns the first element matching a CSS selector;querySelectorAllreturns all of them.el.closest('tr')walks up to the nearest matching parent. - Create:
document.createElement('tr'), thenparent.append(child). - Change:
el.textContent = 'Out of stock',el.classList.toggle('out', qty === 0),el.dataset.code = 'A1001'(which becomes the attributedata-code="A1001"). - Replace:
tbody.replaceChildren(...rows)swaps all children in one step.
One rule matters for security from day one. textContent puts text on the page as text. innerHTML parses a string as HTML. If that string contains data from a user or an API, such as a product name a supplier typed into the back office, someone can inject a <script> or an <img onerror> that runs in your users' browsers. This is called XSS, and Security and quality in JavaScript projects covers it fully. For now: data goes in with textContent.
Quick check
A product name from the API is Rice <b>5 kg</b>. You show it with cell.textContent = name. What does the shopper see?
textContent never parses HTML, so the tags appear as ordinary characters. Bold text would only appear with innerHTML, which is the same door XSS uses. Browsers do not refuse such text; they display it.
Events: bubbling and delegation
The browser tells your code what the user did through events. You subscribe with el.addEventListener('click', handler), and the handler receives an event object. Two of its properties matter most: event.target, the element the user actually touched, and event.currentTarget, the element whose listener is running now.
Those can be different because of bubbling. A click on a button first runs the button's own listeners, then travels up the tree: the cell, the row, the table body, the table, the body. Every listener on the way runs, and event.target is still the button. Paste this into the DevTools Console on a tiny page with one table row and see it:
// Paste into the DevTools Console on bubble.html
const button = document.querySelector('button');
const path = [button, button.closest('td'), button.closest('tr'),
button.closest('tbody'), button.closest('table'), document.body];
for (const el of path) {
el.addEventListener('click', (e) =>
console.log('reached', e.currentTarget.tagName, '| target:', e.target.tagName));
}
button.click(); // a real tap does the samereached BUTTON | target: BUTTON
reached TD | target: BUTTON
reached TR | target: BUTTON
reached TBODY | target: BUTTON
reached TABLE | target: BUTTON
reached BODY | target: BUTTONBubbling gives you event delegation. A stock table with 2,000 rows and two buttons per row does not need 4,000 listeners. Put one listener on the <tbody>, and in it ask "which button, in which row?" with event.target.closest('button') and the row's data-code. It also works for rows you add later, after a search or a refresh, because the listener sits on the parent that stays.
Forms add one more habit. When a form is submitted, by a button or by Enter, the browser's default is to reload the page with the form data in the URL. In a JavaScript page you almost always call event.preventDefault() in the submit handler and do the search yourself. Listening to submit instead of a button's click also gives you Enter for free.
Quick check
One listener sits on <tbody>. A user taps a "DC stock" button inside a row. Inside the handler, what is event.currentTarget?
currentTarget is the element whose listener is running now, here the tbody. The button is event.target, the element actually touched. The row is something you find yourself with closest('tr'); the event never points at it on its own.
event.target), then bubbles up through the cell, the row and the table body. One listener on <tbody> catches clicks from every row, and reads the row's data-code to know which article was meant.fetch, JSON and every state the user can see
fetch(url) returns a promise for a Response (Async JavaScript). await res.json() reads the body and parses it as JSON. For a POST you pass a method, headers and a body string made with JSON.stringify. The trap you already met: fetch only rejects when the request could not complete at all (no network, aborted, blocked). A 404 or 500 is a successful fetch with res.ok === false, so check it yourself.
A real screen has more states than "data" and "nothing": loading, loaded, loaded but empty ("No article matches"), too slow (a timeout), no connection, and server error. On flaky store internet the last three happen every day. Design each one: what does the cashier see, and can she retry? The Build it page handles all six.
CORS in plain words
An origin is the scheme, host and port together: http://localhost:5173 and http://localhost:4000 are different origins, because the ports differ. Browsers follow the same-origin policy: a page's JavaScript can read responses from its own origin, and from other origins only if that server says it is allowed. Without this rule, any website you visited could quietly read your company dashboard using your login.
The permission system is CORS. When mangrove-dashboard on one origin calls partner-api on another, the browser adds an Origin header. The API answers with Access-Control-Allow-Origin naming that origin (or * for public data without logins). If the header is missing, the request still reaches the server, the server still runs it, and the browser refuses to give the response to your code. That is why "it works in Postman and curl" means nothing here: those are not browsers, and CORS is enforced only by browsers.
Some requests need permission before they are sent. A preflight is an automatic OPTIONS request asking "may I send a POST with a content-type header?". It happens for any method other than GET, HEAD or POST, for custom headers such as Authorization, and for a POST with a JSON body. The server must answer with the allowed methods and headers; only then does the browser send the real POST. Access-Control-Max-Age lets the browser cache that answer for a while. In NestJS, all of this is one call, app.enableCors({ origin: [...] }) (From script to service).
Quick check
Which pair of addresses is the same origin?
An origin is the scheme, host and port; the path does not count, so the first pair is one origin. A different port, a different subdomain, or http versus https each make a different origin, so the other three would need CORS.
OPTIONS preflight with the page's Origin. Only if the API answers with the right Access-Control-Allow-* headers does the browser send the real POST, and only then can the page read the response.Storage, rendering, and where React fits
Where to keep data in the browser. localStorage keeps strings per origin until someone clears them; sessionStorage is the same but is cleared when the tab closes. MDN puts the limit at about 5 MiB each, per origin. Both are readable by any script on the page, so never put tokens or personal data there. Cookies are different: the browser sends them with every request to that site, and a cookie marked HttpOnly cannot be read by JavaScript at all, which is why login sessions usually live there. Use localStorage for harmless preferences: the last search, a chosen store, dark mode.
From DOM change to pixels. After your code changes the DOM, the browser runs a pipeline: work out styles, then layout (where every box goes and how big it is), then paint (filling in the pixels), then puts the layers together on screen. Layout is the expensive step on a big page. Normally the browser batches your changes and runs layout once, between tasks (The event loop). But if you read a size, such as offsetHeight, right after a write, the browser must run layout immediately to answer you. Write, read, write, read in a loop, and you force thousands of layouts. In the Build it section, 2,000 rows added that way take about 2.5 seconds in headless Chrome; built first and added in one replaceChildren, about 25 ms.
React exists so you do not manage this by hand. You write components: functions that take data and return what the screen should look like. When state changes (the search text), React calls your component again, compares the new description with the previous one (the "virtual DOM"), and changes only the DOM nodes that differ. JSX text is escaped for you, like textContent. The same table looks like this:
// StockTable.jsx: the same table in React. You describe the UI for the current state;
// React works out which DOM nodes to change.
import { useState } from 'react';
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export function StockTable({ items }) {
const [query, setQuery] = useState(''); // state: React re-renders when it changes
const q = query.trim().toLowerCase();
const found = items.filter((i) => `${i.articleCode} ${i.name}`.toLowerCase().includes(q));
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="A1001 or rice" />
<p>{found.length} articles</p>
<table>
<tbody>
{found.map((item) => (
<tr key={item.articleCode} className={item.qty === 0 ? 'out' : ''}>
<td>{item.articleCode}</td>
<td>{item.name}</td>{/* text is escaped for you, like textContent */}
<td>{item.qty}</td>
<td>{usd.format(item.priceCents / 100)}</td>
</tr>
))}
</tbody>
</table>
</>
);
}React Native, which store-app uses, keeps the components, state and JSX, and replaces the DOM. There is no document, no <div> and no CSS files. You write <View>, <Text> and <FlatList>, and React Native draws real Android and iOS views. fetch and promises work the same way. CORS does not apply, because the app is not a web page loaded from an origin. The same API call from a browser page would need it.
Think first: a teammate says "the API works in Postman, so the CORS error must be a browser bug". What do you tell them?
Postman is not a browser, so it ignores CORS completely. The browser is doing its job: the API did not include an Access-Control-Allow-Origin header for the page's origin (or did not answer the preflight). Open the DevTools Network panel, find the OPTIONS or the request, and check the response headers. The fix goes on the server, by allowing that exact origin. The page cannot fix it.
Build it
Build a store stock lookup page in plain HTML and JavaScript: it loads a JSON file, searches it, uses one delegated listener for every row button, calls a second "DC stock" API on another origin, and shows every error state. Then break CORS on purpose and fix it. The delegation logic from step 4 is also a Practice exercise below, which you can solve and check right in your browser.
-
Create the files and the data
The
package.jsonis only there so the small Node API in step 6 can useimport. Create the files below.stock.jsonstands in for an API response; prices are integer cents, so no rounding errors creep in (Working with data).Terminalmkdir stock-lookup cd stock-lookup npm init -y npm pkg set type=module mkdir public code .HTML<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Store stock lookup</title> <link rel="stylesheet" href="style.css"> </head> <body> <h1>Stock at <span id="site">...</span></h1> <form id="search"> <label for="q">Article code or name</label> <input id="q" name="q" autocomplete="off" placeholder="A1001 or rice"> <button type="submit">Search</button> </form> <p id="status" role="status">Loading stock...</p> <table> <thead><tr><th>Code</th><th>Article</th><th>Qty</th><th>Price</th><th></th></tr></thead> <tbody id="rows"></tbody> </table> <script type="module" src="app.js"></script> </body> </html>JSON{ "siteId": "D027", "updatedAt": "2026-10-07T09:30:00Z", "items": [ { "articleCode": "A1001", "name": "Rice 5 kg", "qty": 42, "priceCents": 840 }, { "articleCode": "A2040", "name": "Soybean oil 2 L", "qty": 0, "priceCents": 675 }, { "articleCode": "A3112", "name": "Red lentils 1 kg", "qty": 7, "priceCents": 290 }, { "articleCode": "A4410", "name": "Whole milk 1 L", "qty": 18, "priceCents": 125 } ] }CSSbody { font-family: system-ui, sans-serif; margin: 16px; max-width: 720px; } form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } table { border-collapse: collapse; width: 100%; margin-top: 12px; } th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #ddd; } tr.out td { color: #b00020; } /* out-of-stock rows */ #status.error { color: #b00020; font-weight: 600; } -
Render rows safely, in one DOM change
Every value goes in as text.
appendturns strings into text nodes, never HTML. The whole table body is replaced in one call, so the browser does one layout.JavaScript// render.js: build table rows from data, safely const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); function cell(...children) { const td = document.createElement('td'); td.append(...children); // strings become text nodes, never HTML return td; } function button(label, action) { const b = document.createElement('button'); b.textContent = label; b.dataset.action = action; // shows up as data-action="dc" in the HTML return b; } export function render(tbody, list) { const rows = list.map((item) => { const tr = document.createElement('tr'); tr.dataset.code = item.articleCode; tr.classList.toggle('out', item.qty === 0); // red when out of stock tr.append( cell(item.articleCode), cell(item.name), cell(String(item.qty)), cell(usd.format(item.priceCents / 100)), // cents to dollars only for display cell(button('DC stock', 'dc'), ' ', button('Request 10', 'request')), ); return tr; }); tbody.replaceChildren(...rows); // one DOM change for the whole table } -
Load, search and handle every state
The form's
submithandler covers both the button and Enter. The last search is kept inlocalStorage, so it survives a reload. Each failure gets a message a cashier understands.JavaScript// app.js: load the stock file, search it, wire up the events import { render } from './render.js'; import { handleRowClick } from './actions.js'; const tbody = document.querySelector('#rows'); const status = document.querySelector('#status'); const input = document.querySelector('#q'); let items = []; function showStatus(text, isError = false) { status.textContent = text; status.classList.toggle('error', isError); } function search(event) { event?.preventDefault(); // stop the form from reloading the page const q = input.value.trim().toLowerCase(); localStorage.setItem('lastQuery', q); // remembered on this device, for this site const found = items.filter((i) => `${i.articleCode} ${i.name}`.toLowerCase().includes(q)); render(tbody, found); showStatus(found.length ? `${found.length} articles` : 'No article matches.'); } async function loadStock() { try { const res = await fetch('stock.json', { signal: AbortSignal.timeout(8000) }); if (!res.ok) throw new Error(`Could not load stock (HTTP ${res.status})`); const data = await res.json(); document.querySelector('#site').textContent = data.siteId; items = data.items; input.value = localStorage.getItem('lastQuery') ?? ''; search(); } catch (err) { const message = err.name === 'TimeoutError' ? 'The server is too slow. Try again.' : err instanceof TypeError ? 'No connection. Check the store internet.' : // fetch network error err.message; showStatus(message, true); } } document.querySelector('#search').addEventListener('submit', search); tbody.addEventListener('click', handleRowClick); // one listener for every button loadStock(); -
Handle every row's buttons with one listener
JavaScript// actions.js: one click handler for every button in the table (event delegation) const API = 'http://localhost:4000'; // another origin: port 4000, not 5173 export async function handleRowClick(event) { const button = event.target.closest('button[data-action]'); if (!button) return; // the click was on a row, not a button const code = button.closest('tr').dataset.code; const cell = button.parentElement; button.disabled = true; try { let res; if (button.dataset.action === 'dc') { res = await fetch(`${API}/dc-stock/${code}`); // a simple GET: no preflight } else { res = await fetch(`${API}/requests`, { method: 'POST', headers: { 'content-type': 'application/json' }, // JSON body: preflight first body: JSON.stringify({ siteId: 'D027', articleCode: code, qty: 10 }), }); } if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); cell.textContent = button.dataset.action === 'dc' ? `${data.dcQty} at DC` : `Sent ${data.id}`; } catch (err) { console.error(err); // a CORS block arrives here as a TypeError cell.textContent = `Failed: ${err.message}`; } } -
Serve the page over HTTP
ES modules do not load from a
file://page (Chrome blocks them with a CORS error), so serve the folder.serveis a small static file server;npxdownloads and runs it.Terminalnpx serve public -l 5173Output┌───────────────────────────────────────┐ │ │ │ Serving! │ │ │ │ - Local: http://0.0.0.0:5173 │ │ - Network: http://192.0.2.2:5173 │ │ │ └───────────────────────────────────────┘Open
http://localhost:5173. You see 4 articles, soybean oil in red. Typeoiland press Enter: one row, and the page does not reload. Reload the page: the search box still saysoil. To see the error states, renamestock.json(you get "Could not load stock (HTTP 404)"), or open DevTools with F12 (Cmd+Option+I on macOS), go to the Network panel, set throttling to "Offline" and reload ("No connection. Check the store internet."). -
Add a DC API on another origin, without CORS first
Create
dc-api.jsnext to thepublicfolder (not inside it). It logs every request, so you can see what the browser really sends.JavaScript// dc-api.js: a tiny "DC stock" API on another origin. node dc-api.js [--no-cors] import { createServer } from 'node:http'; const PAGE_ORIGIN = 'http://localhost:5173'; const CORS = !process.argv.includes('--no-cors'); const dcStock = { A1001: 1200, A2040: 0, A3112: 340, A4410: 95 }; createServer((req, res) => { console.log(req.method, req.url, 'Origin:', req.headers.origin ?? '-'); if (CORS && req.headers.origin === PAGE_ORIGIN) { res.setHeader('Access-Control-Allow-Origin', PAGE_ORIGIN); // "this page may read me" res.setHeader('Vary', 'Origin'); // caches: answer depends on Origin } if (CORS && req.method === 'OPTIONS') { // answer the preflight res.writeHead(204, { 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Allow-Headers': 'content-type', 'Access-Control-Max-Age': '600', // the browser may reuse this answer for 10 min }); return res.end(); } const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }; const [, route, code] = req.url.split('/'); if (req.method === 'GET' && route === 'dc-stock' && code in dcStock) { return send(200, { articleCode: code, dcQty: dcStock[code] }); } if (req.method === 'POST' && route === 'requests') { let body = ''; req.on('data', (chunk) => (body += chunk)); req.on('end', () => send(201, { id: 'REQ-1042', ...JSON.parse(body) })); return; } send(404, { error: 'not found' }); }).listen(4000, () => console.log(`DC API on http://localhost:4000, CORS ${CORS ? 'on' : 'off'}`));In a second terminal, start it with CORS off. Click "DC stock" on lentils, then "Request 10" on rice.
Terminalnode dc-api.js --no-corsOutputDC API on http://localhost:4000, CORS off GET /dc-stock/A3112 Origin: http://localhost:5173 OPTIONS /requests Origin: http://localhost:5173The browser Console shows:
OutputAccess to fetch at 'http://localhost:4000/dc-stock/A3112' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Failed to load resource: net::ERR_FAILED TypeError: Failed to fetch at HTMLTableSectionElement.handleRowClick (http://localhost:5173/actions.js:13:19) Access to fetch at 'http://localhost:4000/requests' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.Read the server log carefully. The GET reached the server and ran; the browser only hid the answer. For the JSON POST, only the
OPTIONSpreflight arrived, and because it was not allowed, the real POST was never sent. In your code, both arrive as aTypeError: Failed to fetchwith no detail, by design. -
Turn CORS on and check the preflight
Stop the API with Ctrl+C, start it with
node dc-api.js, and click the same two buttons. The cells now show "340 at DC" and "Sent REQ-1042":OutputDC API on http://localhost:4000, CORS on GET /dc-stock/A3112 Origin: http://localhost:5173 OPTIONS /requests Origin: http://localhost:5173 POST /requests Origin: http://localhost:5173You can ask the preflight question yourself with curl (bash; in PowerShell use
curl.exe, and put the command on one line):Terminalcurl -i -X OPTIONS http://localhost:4000/requests \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type"OutputHTTP/1.1 204 No Content Access-Control-Allow-Origin: http://localhost:5173 Vary: Origin Access-Control-Allow-Methods: GET, POST Access-Control-Allow-Headers: content-type Access-Control-Max-Age: 600 Date: Wed, 07 Oct 2026 05:42:10 GMT -
Feel the cost of forced layouts
Paste this into the DevTools Console on any page. Both functions add 2,000 rows; the first reads
offsetHeightafter each one.JavaScript// thrash.js: paste into the DevTools Console on any page const items = Array.from({ length: 2000 }, (_, i) => ({ articleCode: `A${1000 + i}`, qty: i % 50 })); function slow() { const tbody = document.createElement('tbody'); document.body.append(tbody); for (const item of items) { const tr = document.createElement('tr'); tr.textContent = `${item.articleCode} ${item.qty}`; tbody.append(tr); // write if (tbody.offsetHeight > 1e9) break; // read: forces a layout every time } tbody.remove(); } function fast() { const tbody = document.createElement('tbody'); document.body.append(tbody); tbody.replaceChildren(...items.map((item) => { const tr = document.createElement('tr'); tr.textContent = `${item.articleCode} ${item.qty}`; return tr; })); // all writes at once console.log('height', tbody.offsetHeight); // one read, one layout tbody.remove(); } for (const fn of [slow, fast]) { const t0 = performance.now(); fn(); console.log(fn.name, Math.round(performance.now() - t0), 'ms'); }Outputslow 2569 ms height 36000 fast 26 msYour numbers will differ by machine, but the gap is usually a factor of 50 to 100. Stop both servers with Ctrl+C when you are done.
| Storage | Lives until | Sent to server | Good for |
|---|---|---|---|
localStorage | Cleared by the user or your code | No | Preferences, last search, chosen store |
sessionStorage | The tab closes | No | A half-filled form in one tab |
Cookie (HttpOnly, Secure) | Its expiry date | Yes, with every request to that site | Login sessions; JavaScript cannot read it |
| IndexedDB | Cleared by the user or your code | No | Large offline data, such as a price list |
Bring it to your team
Ask these when reviewing a web dashboard or portal feature.
- Which origins does our API allow in CORS, where is that list kept, and who updates it when we add a domain?
- What does the user see while loading, when the list is empty, when it times out, and when the store is offline?
- Is any API or user data put on the page with
innerHTML? Why nottextContentor JSX? - Are we storing anything sensitive in localStorage, such as a token or a customer's phone number?
- How many rows can this table get at a big store, and do we build them in one update or one by one?
- For a list of hundreds of rows, is there one delegated listener or one per row?
Red flags
Access-Control-Allow-Origin: *on an API with logins or private data.- "It works in Postman" offered as proof that a CORS problem is not on the server.
- Login tokens kept in localStorage.
innerHTMLwith product names, supplier notes or anything a user typed.- A spinner as the only state: no empty, timeout or offline message.
Practice
Write the delegation logic as a pure function
A delegated click handler has two jobs: find the button that was pressed, and find the row it belongs to. Here the browser is taken away so you can test that logic alone. path is the list of elements the click passed through, starting with the one touched (event.target) and ending with the <tbody> that has the listener. Each element is a plain object such as { tag: 'button', data: { action: 'dc' } }. Return { action, code } using the first element that has a data.action, and the first tr after it that has a data.code. Return null when the click was not on a button, or the button is not inside a row.
function resolveClick(path) {
// path[0] is event.target; the last element is the tbody with the listener
// 1. find the first element that has data.action (like closest('button[data-action]'))
// 2. after it, find the first 'tr' with data.code (like button.closest('tr'))
return null;
}
const tbody = { tag: 'tbody', data: {} };
const row = { tag: 'tr', data: { code: 'A3112' } };
const cell = { tag: 'td', data: {} };
const dcButton = { tag: 'button', data: { action: 'dc' } };
test('a tap on the button gives its action and the row code', () => {
expect(resolveClick([dcButton, cell, row, tbody])).toEqual({ action: 'dc', code: 'A3112' });
});
test('a tap on an icon inside the button still finds the button', () => {
const icon = { tag: 'span', data: {} };
const requestButton = { tag: 'button', data: { action: 'request' } };
expect(resolveClick([icon, requestButton, cell, row, tbody])).toEqual({ action: 'request', code: 'A3112' });
});
test('a tap on a cell, not a button, returns null', () => {
expect(resolveClick([cell, row, tbody])).toBe(null);
});
test('a button outside any row returns null', () => {
expect(resolveClick([dcButton, tbody])).toBe(null);
});
Show a solution
function resolveClick(path) {
const i = path.findIndex((el) => el.data && el.data.action);
if (i === -1) return null; // not on a button: ignore the click
const row = path.slice(i + 1).find((el) => el.tag === 'tr' && el.data.code);
if (!row) return null; // a button outside the rows
return { action: path[i].data.action, code: row.data.code };
}Diagnose a CORS failure from the evidence
A page at https://dash.example.com calls https://api.example.com/orders with a POST and a JSON body. The page shows "Failed to fetch". The API log has one line: OPTIONS /orders Origin: https://dash.example.com, answered with 204 and two headers, Access-Control-Allow-Origin: https://dash.example.com and Access-Control-Allow-Methods: GET, POST. Write three or four sentences: what the browser did, why the real POST never arrived, and the exact fix.
- You say the OPTIONS line is the preflight, sent because the POST has a JSON
content-type - You spot that the answer has no
Access-Control-Allow-Headersthat allowscontent-type - You say the browser therefore never sent the POST, so the server never ran it
- Your fix is on the API, not the page, and keeps the exact origin instead of
*
Show a model answer
The page sent a cross-origin POST with content-type: application/json, which is not a "simple" request, so the browser first sent an OPTIONS preflight asking whether that method and header are allowed. The API allowed the origin and the POST method, but its answer had no Access-Control-Allow-Headers line, so the content-type header was not allowed. The browser stopped there: the real POST was never sent, which is why the log has only the OPTIONS line, and the page got a bare "Failed to fetch". The fix is on the API: add Access-Control-Allow-Headers: content-type to the preflight answer (in NestJS, the allowedHeaders option of enableCors), and keep allowing only https://dash.example.com.
Check yourself
A table has 500 rows, each with a "Request" button. What is the best way to handle the clicks?
Clicks bubble up, so one listener on the parent sees every button's click and event.target says which one. It uses less memory and keeps working for rows added later. Per-button listeners work but must be re-added after every re-render.
A page on http://localhost:5173 does a simple GET to http://localhost:4000, which sends no CORS headers. What happens?
Different ports mean different origins. A simple GET is sent, the server runs it, and the browser refuses to expose the answer. That is why a GET that changes data is dangerous even with CORS. Only non-simple requests, such as a JSON POST, are stopped at the preflight.
Why can adding 2,000 rows one by one, and reading offsetHeight after each, take seconds?
Normally the browser batches DOM changes and runs layout once. Reading a size after a write forces an immediate layout to give an exact answer. Build the rows first and insert them in one step, then read once.
Flashcards
Key takeaways
- The DOM is a live tree. Find with
querySelectorandclosest, change withtextContentandclassList, and keep user or API data out ofinnerHTML. - Events bubble up the tree, so one delegated listener can serve a whole table. Call
preventDefault()on form submits. - Check
res.ok, add a timeout, and design every state: loading, empty, slow, offline, error. - CORS is the server telling the browser which origins may read its responses. Allow exact origins; a JSON POST needs a preflight answer first.
- Batch DOM writes and avoid read-after-write in loops. React does this diffing for you; React Native keeps React and swaps the DOM for native views.
Sources
- Cross-Origin Resource Sharing (CORS), MDN Web Docs
- Event bubbling, MDN Web Docs
- Avoid large, complex layouts and layout thrashing, web.dev