1. Courses
  2. Developer Foundations
  3. JavaScript in the Real World
  4. Stop 1

35 minIntermediate

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 , 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; querySelectorAll returns all of them. el.closest('tr') walks up to the nearest matching parent.
  • Create: document.createElement('tr'), then parent.append(child).
  • Change: el.textContent = 'Out of stock', el.classList.toggle('out', qty === 0), el.dataset.code = 'A1001' (which becomes the attribute data-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 , 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?

Events: bubbling and delegation

The browser tells your code what the user did through . 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 . 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:

JavaScript
// 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 same
Output
reached BUTTON | target: BUTTON
reached TD | target: BUTTON
reached TR | target: BUTTON
reached TBODY | target: BUTTON
reached TABLE | target: BUTTON
reached BODY | target: BUTTON

Bubbling gives you . 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?

<table> <tbody id="rows"> one listener here <tr data-code="A1001"> <td> <button> DC stock bubbles up td, tr, tbody, table, body 1. tap here event.target 2. handled: closest('tr')
The tap lands on the button (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 is the scheme, host and port together: http://localhost:5173 and http://localhost:4000 are different origins, because the ports differ. Browsers follow the : 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 . 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 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?

Page :5173 Browser API :4000 fetch POST JSON body 1. OPTIONS Origin: :5173 2. 204 allowed Allow-Origin Allow-Headers 3. real POST 4. 201 + header 5. res.json() Allow-Origin missing? Then step 3 never runs.
A cross-origin POST with a JSON body. The browser first sends an 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. 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 (where every box goes and how big it is), then (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 : functions that take data and return what the screen should look like. When 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:

JavaScript
// 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.

  1. Create the files and the data

    The package.json is only there so the small Node API in step 6 can use import. Create the files below. stock.json stands in for an API response; prices are integer cents, so no rounding errors creep in (Working with data).

    Terminal
    mkdir 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>
  2. Render rows safely, in one DOM change

    Every value goes in as text. append turns 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
    }
  3. Load, search and handle every state

    The form's submit handler covers both the button and Enter. The last search is kept in localStorage, 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();
  4. 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}`;
      }
    }
  5. 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. serve is a small static file server; npx downloads and runs it.

    Terminal
    npx serve public -l 5173
    Output
       ┌───────────────────────────────────────┐
       │                                       │
       │   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. Type oil and press Enter: one row, and the page does not reload. Reload the page: the search box still says oil. To see the error states, rename stock.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.").

  6. Add a DC API on another origin, without CORS first

    Create dc-api.js next to the public folder (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.

    Terminal
    node dc-api.js --no-cors
    Output
    DC API on http://localhost:4000, CORS off
    GET /dc-stock/A3112 Origin: http://localhost:5173
    OPTIONS /requests Origin: http://localhost:5173

    The browser Console shows:

    Output
    Access 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 OPTIONS preflight arrived, and because it was not allowed, the real POST was never sent. In your code, both arrive as a TypeError: Failed to fetch with no detail, by design.

  7. 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":

    Output
    DC 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:5173

    You can ask the preflight question yourself with curl (bash; in PowerShell use curl.exe, and put the command on one line):

    Terminal
    curl -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"
    Output
    HTTP/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
  8. Feel the cost of forced layouts

    Paste this into the DevTools Console on any page. Both functions add 2,000 rows; the first reads offsetHeight after 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');
    }
    Output
    slow 2569 ms
    height 36000
    fast 26 ms

    Your 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.

StorageLives untilSent to serverGood for
localStorageCleared by the user or your codeNoPreferences, last search, chosen store
sessionStorageThe tab closesNoA half-filled form in one tab
Cookie (HttpOnly, Secure)Its expiry dateYes, with every request to that siteLogin sessions; JavaScript cannot read it
IndexedDBCleared by the user or your codeNoLarge 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 not textContent or 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.
  • innerHTML with 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.

JavaScript exerciseRuns in your browser with tests
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;
}
Ctrl + Enter runs
Show a solution
JavaScript
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 };
}
Pass the exercise to finish this task.

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-Headers that allows content-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?

A page on http://localhost:5173 does a simple GET to http://localhost:4000, which sends no CORS headers. What happens?

Why can adding 2,000 rows one by one, and reading offsetHeight after each, take seconds?

Flashcards

Open my review

Key takeaways

  • The DOM is a live tree. Find with querySelector and closest, change with textContent and classList, and keep user or API data out of innerHTML.
  • 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

Episode transcriptThe shop window and the CORS gatekeeper · about 13 minutes · tap any line to play from there
  1. NadiaImran, festival campaign launch morning. The dashboard moved to a new address for the regional managers. Every chart says failed to fetch. And the developers say the API is fine, they can call it from the terminal. Who's right?
  2. ImranFrom experience, both sides, and the meeting gets loud.
  3. NadiaBoth are right, and by the end of this episode you'll know exactly why. Today is JavaScript in the browser: the page as a tree, events, fetch, CORS, and why some pages freeze.
  4. NadiaStart with a Mangrove store's front window. The HTML is what's in the window: shelves, price boards, the festival poster. The CSS is the decoration: colours, lighting, where things stand. And JavaScript is the staff inside, reacting when a customer points at something.
  5. ImranSo when a customer asks about one product, a good staff member changes one price board. Not the whole window.
  6. NadiaExactly. If they rearrange the whole window for every question, everyone outside waits. That's most of browser performance in one picture.
  7. ImranLet's start with the tree. The DOM.
  8. NadiaWhen the browser reads your HTML, it builds a live tree of objects. A table contains a table body, which contains rows, which contain cells. JavaScript never edits the HTML file. It changes this tree, and the browser redraws what changed.
  9. ImranAnd the everyday operations are find, create, change and replace.
  10. NadiaYes. Find with query selector, or closest to walk up to a parent. Create an element and append it. Change its text or its classes. Or replace all the children of a table body in one go.
  11. NadiaOne security rule from day one. Setting text content puts text on the page as plain text. Setting inner HTML parses the string as HTML. If that string is a product name a supplier typed into a portal, someone can sneak in a script that runs in every user's browser.
  12. ImranThat's XSS, cross-site scripting. So data from users or APIs always goes in as text.
  13. NadiaAlways. The security lesson at the end of this course goes deeper. Now events. When a user taps a button, the browser fires a click event, and your listener runs. But then something interesting happens. The event travels up the tree.
  14. NadiaIf you can see the screen, the first diagram is a column of boxes: table at the top, then table body, row, cell, and the button at the bottom. A thick arrow goes from the button back up to the table body. If you're not looking, picture a bubble rising from the button through every parent.
  15. ImranSo a click on the button also runs listeners on the cell, the row, the table body, all the way up to the body of the page.
  16. NadiaRight. And the event remembers where it started. That's the event target. The lesson has a small console experiment where you listen at every level and click once. Six lines print, from button up to body, and the target is the button every time.
  17. ImranLet me guess why that matters. A stock table with two thousand rows and two buttons per row. I'd have added four thousand listeners.
  18. NadiaThat's the reasonable wrong guess. Instead you put one listener on the table body. When a click arrives, you ask which button, and in which row, using closest and the row's article code. That's called event delegation.
  19. ImranAnd it keeps working when the rows change after a search, because the listener lives on the parent that stays.
  20. NadiaExactly. One more habit with forms. When a form is submitted, the browser's default is to reload the page. In a JavaScript page you call prevent default and run the search yourself. And listening to the form's submit, not the button's click, gives you the Enter key for free.
  21. ImranNow fetch. We covered promises in the async lesson of JavaScript Deep Dive.
  22. NadiaThe same trap applies. Fetch only rejects when the request couldn't complete at all. A four oh four or a five hundred still counts as a successful fetch, with the response marked as not okay. So you check that yourself.
  23. ImranAnd the screen has more states than data or nothing.
  24. NadiaSix, really. Loading. Loaded. Loaded but empty, no article matches. Too slow. No connection. And server error. On patchy store internet, the last three happen every day. Each one needs a message a cashier understands, and usually a retry.
  25. ImranOkay, CORS. Explain it like I'm the regional manager staring at an empty dashboard.
  26. NadiaFirst, an origin. It's the scheme, the host and the port together. A page on localhost port fifty-one seventy-three and an API on localhost port four thousand are different origins, because the ports differ.
  27. NadiaBrowsers follow the same-origin policy. A page's JavaScript can freely read responses from its own origin. From any other origin, only if that server says it's allowed. Otherwise any website you visit could quietly read your company dashboard using your login.
  28. ImranAnd CORS is how the server says it's allowed.
  29. NadiaYes. The browser adds an origin header. The API answers with an allow origin header naming that page's origin. If that header is missing, here's the surprising part: the request still reaches the server, and the server still runs it. The browser just refuses to hand the response to your code.
  30. ImranSo that's my launch morning. The developers called the API from the terminal, and the terminal isn't a browser, so it ignores CORS.
  31. NadiaExactly. Curl and Postman don't enforce CORS. Only browsers do. The API's allowed list still had the old dashboard address. The fix is one line on the server.
  32. NadiaSome requests need permission before they're even sent. The second diagram shows three columns: page, browser and API, with arrows going across in order. Step one, the browser sends an OPTIONS request, called a preflight. Step two, the API answers allowed. Only then, step three, the real POST.
  33. ImranWhen does the browser do a preflight?
  34. NadiaFor methods other than GET, HEAD or POST, for custom headers like authorization, and for a POST with a JSON body. Which is most API calls a dashboard makes.
  35. ImranAnd if the preflight isn't allowed, the real request never leaves.
  36. NadiaRight. That's the note at the bottom of the diagram. In NestJS, the server side is one call, enable CORS, with a list of allowed origins.
  37. ImranCan't we just allow every origin with a star and be done?
  38. NadiaBrowsers don't allow the star together with logins or cookies, so it doesn't even work for logged-in requests. And for an internal API, it lets any website a staff member opens read your data. Allow the exact origins you own, and keep the list in config.
  39. ImranQuickly, storage. Local storage, session storage, cookies.
  40. NadiaLocal storage keeps small strings per origin until someone clears them, a few megabytes. Session storage is the same but disappears when the tab closes. Any script on the page can read both, so no tokens and no customer phone numbers there.
  41. NadiaCookies are sent with every request to that site, and a cookie marked HTTP only can't be read by JavaScript at all. That's why login sessions usually live in cookies. Local storage is for harmless things, like the last search.
  42. ImranNow the freezing page. Why are too many DOM changes slow?
  43. NadiaAfter your code changes the tree, the browser runs a pipeline. Styles, then layout, which works out where every box goes and how big it is, then paint, which fills in the pixels. Layout is the expensive step on a big page.
  44. NadiaNormally the browser batches your changes and runs layout once. But if you read a size right after a write, like the height of the table, it must run layout immediately to answer you. Do that in a loop and you force thousands of layouts.
  45. ImranHow big is the difference?
  46. NadiaIn the lesson's test, two thousand rows added one by one with a height read after each took about two and a half seconds in headless Chrome. Built first and inserted in one step, about twenty-five milliseconds. Your machine will give different numbers, but the gap is huge.
  47. ImranAnd React exists so we don't have to manage that by hand.
  48. NadiaYes. You write components, functions that take data and describe what the screen should look like. When state changes, React calls the component again, compares the new description with the old one, and changes only the DOM nodes that differ. And text in JSX is escaped for you, like text content.
  49. ImranAnd React Native, for our Store app?
  50. NadiaKeeps the components, state and JSX, and drops the DOM. No document, no div, no CSS files. You write View and Text, and React Native draws real Android and iOS views. Fetch and promises work the same. And CORS doesn't apply, because the app isn't a web page loaded from an origin.
  51. NadiaThe Build it section is a small store stock lookup page in plain HTML and JavaScript. It loads a stock file, renders rows safely in one update, searches with the form's submit event, and remembers the last search in local storage across a reload.
  52. NadiaThen you add a little DC stock API on another port and start it with CORS switched off. You click DC stock on lentils and Request ten on rice, and watch the server log.
  53. ImranAnd the log shows the GET arrived and ran, but the POST never came. Only the OPTIONS.
  54. NadiaExactly. The browser hid the GET's answer and stopped the POST at the preflight. Your code just sees a type error, failed to fetch, with no detail. Then you switch CORS on, click again, and the cells show three hundred and forty at the DC, and a request number.
  55. NadiaLast, you paste the slow and fast table builders into the console and see the layout cost for yourself.
  56. ImranAnd the delegation part, can I practise that without setting up a server?
  57. NadiaYes. In the Practice section you write the which button, which row logic as one small function, and the page runs tests on it right away. There's also a CORS puzzle: a server log with only an OPTIONS line, and you explain why the real request never came.
  58. NadiaSo what will you ask about the next dashboard feature?
  59. ImranFirst: which origins does our API allow, where's that list, and who updates it when we add a domain? Second: what does the user see while loading, when it's empty, when it times out and when the store is offline? Third: is any API data going onto the page with inner HTML?
  60. NadiaI'd add: how many rows can this table get at a big store, and are we building them in one update?
  61. NadiaOne to think about. A teammate says the API works in Postman, so the CORS error must be a browser bug. What do you say? Take a second.
  62. ImranPostman isn't a browser, so it ignores CORS. The browser is doing its job. Open the network panel, look at the response headers, and fix the allowed origins on the server.
  63. NadiaThat's it.
  64. ImranMy summary. One: the page is a live tree, and data goes in as text, never as HTML. Two: events bubble up, so one listener on the table body handles every row. Three: CORS is the server telling the browser which origins may read its answers, and a JSON POST needs a preflight first.
  65. NadiaGood. One correction to keep in mind on three: without CORS headers, a simple GET still runs on the server. The browser only hides the response. So a GET must never change data.
  66. ImranNext time?
  67. NadiaNode and npm: what really happens when you type npm install, what the little caret in a version number means, and how a lockfile ends the works on my machine problem.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Loyalty card screen (React)

A phone screen with its points rules in a tested helper file.

Open in the Code Lab
Next stopNode.js and npm
Was this lesson helpful?
Questions and discussion

Loading the discussion…