1. Foundations of the Web
This chapter lays the groundwork for everything else in web development. It covers how the Web sits on top of the Internet, how clients and servers find and exchange resources, and how browsers turn bytes into pixels. It then follows web architecture from static documents to modern full-stack frameworks, and ends with the standards bodies, browser engines and tools that shape everyday development. With these foundations in place, later topics such as HTML, JavaScript, HTTP, Node.js and React fit together as one picture instead of a pile of unrelated technologies.
<a id="11-web-architecture-fundamentals"></a>
1.1 Web Architecture Fundamentals
This subchapter covers the building blocks behind every web interaction, from the network that carries the bytes to the browser engine that displays them.
Internet vs World Wide Web
People often use "the Internet" and "the Web" as if they meant the same thing, but they are different layers of technology. Keeping them apart matters because many problems, such as a DNS failure, a TLS error or a broken HTML page, belong to one specific layer, and you have to know which layer you are dealing with before you can fix them.
The Internet is a global network of networks: millions of private, public, academic and government networks connected by routers and joined by a shared set of protocols, the TCP/IP suite. It started as ARPANET in 1969 and adopted TCP/IP on 1 January 1983. The Internet's job is to move packets of data from one machine to another. It does not care what those packets mean.
The World Wide Web is an information system that runs on top of the Internet. Tim Berners-Lee proposed it at CERN in 1989 and the first website went live in 1991. It is built on three core ideas:
| Pillar | Role | Example |
|---|---|---|
| HTML | A language for writing documents with hyperlinks | <a href="/about">About</a> |
| URL | A global address for every resource | https://example.com/about |
| HTTP | A protocol for requesting and sending those resources | GET /about HTTP/1.1 |
Analogy: the Internet is the road network of a country. The Web is one delivery service that uses those roads. Email, video calls, online games, SSH and file sharing are other services on the same roads. If the roads go down, every service stops. If the delivery service goes down, the roads still work.
The layers are easiest to see in the TCP/IP model. Each layer relies on the one below it and hides that layer's details from the one above it.
Example: where the Web sits in the protocol stack
+-------------------+---------------------------------------------+
| Application layer | HTTP (Web), SMTP/IMAP (email), SSH, DNS ... | <- the Web lives here
+-------------------+---------------------------------------------+
| Transport layer | TCP (reliable, ordered), UDP, QUIC |
+-------------------+---------------------------------------------+
| Internet layer | IP (addressing and routing of packets) |
+-------------------+---------------------------------------------+
| Link layer | Ethernet, Wi-Fi, fibre, cellular |
+-------------------+---------------------------------------------+
^ everything from Transport down is "the Internet"
Other application protocols run on the same Internet, each on its own conventional port:
| Service | Protocol | Default port | Part of the Web? |
|---|---|---|---|
| Web pages | HTTP / HTTPS | 80 / 443 | Yes |
| Sending email | SMTP | 25 / 587 | No |
| Remote shell | SSH | 22 | No |
| Name resolution | DNS | 53 | No, but the Web depends on it |
| File transfer | FTP | 21 | No |
You can watch the Web running over the Internet with curl. The -v flag prints the lower-level steps (connection, TLS) before the HTTP exchange:
curl -v https://example.com/
# * Trying 203.0.113.10:443... <- IP + TCP: open a connection to an address and port
# * Connected to example.com
# * SSL connection using TLSv1.3 <- TLS: encrypt the channel
# > GET / HTTP/2 <- HTTP: the Web's request
# > Host: example.com
# < HTTP/2 200 <- HTTP: the Web's response
# < content-type: text/html; charset=UTF-8
Key Takeaways
- The Internet is the global network infrastructure (TCP/IP). The Web is one application that runs on it.
- The Web rests on three pillars: HTML (documents), URLs (addresses) and HTTP (transfer).
- Email, SSH, DNS and many other services use the Internet without being part of the Web.
- HTTP is an application-layer protocol. It relies on a transport protocol (TCP, or QUIC for HTTP/3) for reliable delivery.
Practice
- (Beginner) Name three Internet services that are not part of the Web. For each, give its protocol and default port.
- (Intermediate) Run
curl -v https://example.com/and annotate each output line with the protocol layer it belongs to (transport, security, application).- (Interview) "Why is HTTP called an application-layer protocol, and what does it expect from the layer below it?" Hint: think about what would happen to an HTML document if packets arrived out of order or got lost, and which protocol prevents that.
Client-Server Model
Imagine every user's computer storing its own copy of a bank's account data. Keeping those copies consistent and secure would be impossible. The client-server model fixes this by putting shared data and logic on a central machine, the server, which many clients access over the network.
Each side has a clear role:
- The client starts communication. It sends a request and waits for a response. Browsers, mobile apps,
curland other servers calling an API are all clients. - The server listens on a known address and port, handles each incoming request and sends back a response. It never contacts a client first in the classic HTTP model.
Analogy: a restaurant. The customer (client) reads the menu and orders. The kitchen (server) prepares exactly what was ordered and sends it out. The kitchen serves many tables at once and never walks up to a table to cook something unrequested.
On the Web this exchange follows the request-response cycle: one request, one response. HTTP is stateless, which means the server does not automatically remember earlier requests from the same client. Applications add memory on top of it with cookies, sessions and tokens (covered in Chapter 10).
Larger applications split the server side into tiers, so that each tier can scale and change on its own:
Presentation tier Application tier Data tier
+------------------+ +--------------------+ +----------------+
| Browser / App | HTTP | Web / API server | SQL/ | Database |
| (HTML, CSS, JS) |<---->| (business logic) |<---->| (persistent |
| | | Node, Java, PHP | TCP | storage) |
+------------------+ +--------------------+ +----------------+
client server server
Some useful terms:
| Term | Meaning |
|---|---|
| Thin client | Does little work itself and mostly shows what the server sends (classic server-rendered pages) |
| Thick (rich) client | Runs a lot of logic locally (single page applications, desktop apps) |
| Two-tier | Client talks directly to a database server |
| Three-tier / n-tier | Presentation, application logic and data are separate layers |
The main alternative is peer-to-peer (P2P), where each node acts as both client and server:
| Aspect | Client-server | Peer-to-peer |
|---|---|---|
| Control | Centralised | Distributed |
| Single point of failure | Yes (the server), reduced by replication | No |
| Data consistency | Easy, one source of truth | Hard |
| Scaling cost | Paid by the server operator | Shared by peers |
| Examples | Websites, REST APIs, email | BitTorrent, WebRTC calls, blockchains |
Example: a minimal server and client
// server.js — a minimal HTTP server (run with: node server.js)
const http = require('node:http');
const server = http.createServer((req, res) => {
// Every request receives exactly one response: status, headers, body
if (req.url === '/greeting') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Hello from the server' }));
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
}
});
// The server binds to a port and waits passively for clients
server.listen(3000, () => console.log('Listening on http://localhost:3000'));
// client.mjs — a client (Node 18+ or a browser console); the client starts the exchange
const response = await fetch('http://localhost:3000/greeting');
console.log(response.status); // 200
const data = await response.json(); // parse the JSON body
console.log(data.message); // "Hello from the server"
Key Takeaways
- Clients start requests. Servers listen and respond.
- Centralising data and logic on servers gives one source of truth and easier security.
- HTTP follows a stateless request-response cycle. State has to be added on purpose.
- Multi-tier architectures separate presentation, logic and data so each can scale on its own.
- Peer-to-peer removes the central server at the cost of consistency and coordination.
Practice
- (Beginner) Run the server above and request
/greetingand/missingwith a browser and withcurl. Compare the status codes.- (Intermediate) Draw a three-tier diagram for an online bookstore. Label which tier handles "show book list", "calculate shipping cost" and "store order".
- (Interview) "Your API server suddenly receives ten times its usual traffic. How does the client-server model let you scale it?" Hint: think about horizontal scaling behind a load balancer, and why it is much easier when the servers keep no per-user state in memory.
URI, URL and URN
Every resource on the Web needs a name so it can be linked, bookmarked, cached and requested. The standards define three related terms that are easy to mix up.
- URI (Uniform Resource Identifier): any string that identifies a resource. This is the umbrella term.
- URL (Uniform Resource Locator): a URI that also says where the resource is and how to get it (scheme plus location), for example
https://example.com/logo.png. - URN (Uniform Resource Name): a URI that gives a resource a persistent, location-independent name under the
urn:scheme, for exampleurn:isbn:0451450523for a book. A URN alone cannot be fetched.
URI (identifies)
+-----------------------------+
| |
+---------+ +-----------+
| URL | | URN |
| locates | | names |
+---------+ +-----------+
https://example.com/a urn:isbn:0451450523
mailto:ada@example.com urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66
Analogy: your full name identifies you no matter where you live (URN). Your home address tells someone how to reach you (URL). Both are identifiers (URIs).
In practice the WHATWG URL Standard, which browsers follow, simply uses "URL" for everything, and most developers do the same. The difference still comes up in specifications and interviews.
Anatomy of a URL
https://ada:secret@shop.example.com:8443/products/search?q=red+shoes&page=2#reviews
\___/ \________/ \______________/ \__/\______________/ \_________________/ \_____/
scheme userinfo host port path query fragment
\_________________________________/
authority
| Part | Purpose | Notes |
|---|---|---|
| Scheme | Protocol to use | http, https, ftp, mailto, file, data |
| Userinfo | Credentials | Deprecated in practice and a phishing risk |
| Host | Domain name or IP address | Resolved by DNS |
| Port | TCP/UDP port | Omitted when it is the scheme default (80 for HTTP, 443 for HTTPS) |
| Path | Resource location on the server | Hierarchical, separated by / |
| Query | Key-value parameters | Starts with ?, pairs separated by & |
| Fragment | Location inside the resource | Starts with #. Never sent to the server |
URLs may only contain a limited set of ASCII characters. Anything else, including characters that have a special meaning such as &, ?, # or a space, must be percent-encoded as % followed by two hex digits (a space becomes %20). In HTML form submissions a space can also be written as +.
Relative URLs are resolved against a base URL, usually the current page:
Base: https://example.com/docs/guide/index.html | Resolves to |
|---|---|
intro.html | https://example.com/docs/guide/intro.html |
../img/logo.png | https://example.com/docs/img/logo.png |
/about | https://example.com/about |
//cdn.example.net/lib.js | https://cdn.example.net/lib.js (scheme-relative) |
?page=3 | https://example.com/docs/guide/index.html?page=3 |
Example: the URL API in JavaScript
const url = new URL(
'https://ada:secret@shop.example.com:8443/products/search?q=red+shoes&page=2#reviews'
);
url.protocol; // 'https:'
url.username; // 'ada'
url.hostname; // 'shop.example.com'
url.port; // '8443' (would be '' for the default port 443)
url.pathname; // '/products/search'
url.search; // '?q=red+shoes&page=2'
url.searchParams.get('q'); // 'red shoes' ('+' is decoded as a space)
url.hash; // '#reviews'
url.origin; // 'https://shop.example.com:8443' (scheme + host + port)
// Modify query parameters safely, with encoding handled for you
url.searchParams.set('page', '3');
url.searchParams.append('color', 'dark red & blue');
url.search; // '?q=red+shoes&page=3&color=dark+red+%26+blue'
// Resolve a relative URL against a base
new URL('../img/logo.png', 'https://example.com/docs/guide/index.html').href;
// 'https://example.com/docs/img/logo.png'
// Encode a single value before inserting it into a URL by hand
encodeURIComponent('a b&c/d'); // 'a%20b%26c%2Fd' (encodes reserved characters too)
encodeURI('https://x.com/a b'); // 'https://x.com/a%20b' (keeps ':', '/', '?', '&' intact)
Key Takeaways
- URI is the general term. A URL tells you how to locate a resource; a URN gives it a persistent name.
- A URL is made of scheme, authority (userinfo, host, port), path, query and fragment.
- The fragment stays in the browser and is never sent in the HTTP request.
- Use
encodeURIComponentorURLSearchParamsfor individual values, and never build query strings by plain string concatenation. - The
URLconstructor parses, validates and resolves relative URLs reliably.
Practice
- (Beginner) Split
http://localhost:5173/api/users/42?fields=name,email#topinto its components and give the effective port.- (Intermediate) Write a function
buildSearchUrl(base, params)that takes a base URL and a plain object and returns a correctly encoded URL string. Test it with values that contain spaces,&and non-ASCII characters such ascafé.- (Interview) "Why is the fragment part of a URL not sent to the server, and how did early single page applications use that behaviour?" Hint: think about what the fragment was designed for inside a document, and what happens to the page when only the hash changes.
MIME Types and Content Encoding
A response body is just a sequence of bytes. The same bytes could be an image, a script or a spreadsheet, and file extensions are not reliable over a network because a URL like /api/report has none. The browser needs explicit metadata to decide what to do with a body: render it, run it, display it or download it.
MIME types (also called media types) provide that metadata. They come from MIME (Multipurpose Internet Mail Extensions), originally designed for email attachments, and are sent on the Web in the Content-Type header. The format is:
type/subtype[+suffix][; parameter=value]
text/html; charset=utf-8
image/svg+xml <- "+xml" suffix: SVG uses XML syntax
application/ld+json <- "+json" suffix: JSON-LD uses JSON syntax
| MIME type | Content |
|---|---|
text/html | HTML documents |
text/css | Stylesheets |
text/javascript | JavaScript (the current standard; application/javascript is obsolete) |
application/json | JSON data |
image/png, image/jpeg, image/webp, image/avif | Raster images |
image/svg+xml | Vector images |
font/woff2 | Web fonts |
multipart/form-data | Form submissions that include files |
application/x-www-form-urlencoded | Default HTML form submissions |
application/octet-stream | Arbitrary binary data, which browsers usually download |
Analogy: shipping a parcel. The label "glassware" tells the receiver what is inside (Content-Type). The instruction booklet inside is written in French (charset). The parcel was vacuum-packed to save space and has to be unpacked before use (Content-Encoding).
Three different layers of description are easy to confuse:
| Header / parameter | Question it answers | Example |
|---|---|---|
Content-Type | What kind of data is this? | application/json |
charset parameter | How are text characters turned into bytes? | charset=utf-8 |
Content-Encoding | How was the body transformed (compressed) end to end? | gzip, br, zstd |
Transfer-Encoding | How is the body framed on this single connection hop? | chunked (HTTP/1.1 only) |
Character encoding. Computers store text as numbers. A charset maps characters to bytes. UTF-8 covers every Unicode character, is backward compatible with ASCII and is the only encoding you should use on the Web. Declare it in both the HTTP header and the HTML (<meta charset="utf-8">). If you don't, accented characters can turn into garbage such as café.
Compression. Text resources (HTML, CSS, JS, JSON, SVG) usually shrink by 70 to 90 percent when compressed. The browser lists what it supports in Accept-Encoding, and the server picks one and announces it in Content-Encoding. This is a form of content negotiation. Don't compress formats that are already compressed, such as JPEG, PNG, MP4 or WOFF2.
Content sniffing. When Content-Type is missing or looks wrong, browsers may guess the type from the bytes. Attackers can abuse this, for example by uploading a "picture" that actually contains a script. The header X-Content-Type-Options: nosniff disables guessing.
Example: an HTTP exchange with negotiation
GET /styles/main.css HTTP/1.1
Host: www.example.com
Accept: text/css,*/*;q=0.1
Accept-Encoding: gzip, deflate, br, zstd
HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Content-Encoding: br
Content-Length: 2841
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
Vary: Accept-Encoding tells caches that the response depends on the request's Accept-Encoding, so a gzip copy is never sent to a client that cannot decode it.
Example: setting types and compression in Node.js
const http = require('node:http');
const zlib = require('node:zlib');
http.createServer((req, res) => {
const body = JSON.stringify({ items: Array(1000).fill('repetitive data') });
res.setHeader('Content-Type', 'application/json; charset=utf-8'); // what + charset
res.setHeader('Vary', 'Accept-Encoding'); // caching hint
const accepts = req.headers['accept-encoding'] || '';
if (/\bgzip\b/.test(accepts)) {
res.setHeader('Content-Encoding', 'gzip'); // tell the client how to decode
res.end(zlib.gzipSync(body)); // send compressed bytes
} else {
res.end(body); // fall back to the identity encoding
}
}).listen(3000);
Key Takeaways
Content-Typecarries the MIME type and tells the client how to interpret the body.- The
charsetparameter defines how text maps to bytes. Always use UTF-8. Content-Encoding(gzip, br, zstd) is end-to-end compression negotiated withAccept-Encoding.Transfer-Encodingis per-hop framing.- Send
X-Content-Type-Options: nosniffso browsers don't guess types. - Compress text, not media that is already compressed.
Practice
- (Beginner) Open the Network panel on any site and record the
Content-TypeandContent-Encodingof an HTML document, a stylesheet, a script, an image and an API call.- (Intermediate) Extend the Node example to prefer Brotli (
zlib.brotliCompressSync) when the client acceptsbr, and log the uncompressed and compressed sizes.- (Interview) "What is the difference between
Content-Encoding: gzipandTransfer-Encoding: chunked?" Hint: ask which one describes the resource itself and which one describes a single connection, and what a proxy is allowed to change.
DNS Resolution and Web Hosting
Networks route packets by IP address (203.0.113.10 or 2001:db8::1), but people remember names like example.com. The Domain Name System (DNS) is the distributed directory that turns names into addresses. It also lets a site move to a new server without changing its name.
Analogy: DNS is the phone book of the Internet, split across thousands of offices. No single office knows every number, but each one knows which office to ask next.
The DNS hierarchy
. (root)
/ | \
.com .org .it <- Top-Level Domains (TLD)
/ \ |
example google wikipedia <- second-level domains
/ \
www shop <- subdomains
Read a domain right to left: shop.example.com. is the shop host in the example domain under the com TLD under the root (the trailing dot).
Resolution, step by step
When you type shop.example.com, the lookup checks several caches before anything goes over the network:
Browser ──(1) browser DNS cache? ──(2) OS cache / hosts file?
│
└─(3) ask the RECURSIVE RESOLVER (ISP, 1.1.1.1, 8.8.8.8 ...)
│ cached answer? -> return it immediately
│
├─(4) ROOT server: "ask the .com servers at ..."
├─(5) .com TLD server: "ask example.com's nameservers at ..."
├─(6) AUTHORITATIVE server: "shop.example.com = 203.0.113.42, TTL 300"
│
└─(7) resolver caches the answer for TTL seconds, returns it to the browser
Browser ──(8) opens a TCP/QUIC connection to 203.0.113.42
Every answer carries a TTL (time to live) that controls how long it may be cached. Long TTLs reduce lookups. Short TTLs make changes take effect faster.
Common record types
| Record | Maps | Example |
|---|---|---|
A | Name to IPv4 address | example.com. A 203.0.113.10 |
AAAA | Name to IPv6 address | example.com. AAAA 2001:db8::10 |
CNAME | Name to another name (alias) | www.example.com. CNAME example.com. |
MX | Domain to mail servers, with priority | example.com. MX 10 mail.example.com. |
TXT | Free text: domain verification, SPF, DKIM | example.com. TXT "v=spf1 ..." |
NS | Domain to its authoritative nameservers | example.com. NS ns1.dnshost.net. |
A CNAME cannot sit at the zone apex (example.com itself) next to other records. Many DNS providers offer ALIAS/ANAME records or "CNAME flattening" to work around this.
Example: inspecting DNS from the command line (illustrative output)
# Ask for the A record and show only the answer
dig shop.example.com A +noall +answer
# shop.example.com. 300 IN CNAME shop-lb.hosting-provider.net.
# shop-lb.hosting-provider.net. 60 IN A 203.0.113.42
# ^TTL ^type ^value
# Walk the whole hierarchy from the root, without using caches
dig shop.example.com +trace
# Override DNS locally (useful for testing): /etc/hosts on Linux/macOS,
# C:\Windows\System32\drivers\etc\hosts on Windows
echo "127.0.0.1 myapp.test" | sudo tee -a /etc/hosts
// Resolving names programmatically in Node.js
const dns = require('node:dns').promises;
const ipv4 = await dns.resolve4('example.com'); // e.g. ['203.0.113.10']
const mx = await dns.resolveMx('example.com'); // [{ exchange: 'mail...', priority: 10 }]
Modern browsers can also send DNS queries encrypted over HTTPS (DoH) so that networks along the way cannot read or tamper with them.
Web hosting
Once a name resolves, something has to answer at that IP address. Three separate roles are often provided by different companies:
- Registrar: where you register the domain name.
- DNS provider: runs the authoritative nameservers.
- Host: runs the servers that respond to HTTP.
| Hosting model | What you manage | Typical use |
|---|---|---|
| Shared hosting | Only your files; many sites share one server | Small sites, blogs |
| VPS | A virtual machine with root access | Small apps, custom stacks |
| Dedicated server | A whole physical machine | High, steady loads |
| Cloud IaaS | VMs, networks and disks on demand (AWS EC2, GCP Compute) | Scalable custom infrastructure |
| PaaS | Only your code; the platform runs it (Heroku-style, Render, Fly.io) | Fast deployment of apps |
| Static hosting / CDN | Prebuilt files served from edge locations worldwide | SPAs, static sites, assets |
| Serverless / FaaS | Individual functions run per request | Event-driven APIs, spiky traffic |
Key Takeaways
- DNS turns human-readable names into IP addresses through a hierarchy of root, TLD and authoritative servers.
- Recursive resolvers do the lookup work and cache answers according to each record's TTL.
- Know the core records:
A,AAAA,CNAME,MX,TXT,NS. - Registrar, DNS provider and host are separate roles, even when one company provides all three.
- Hosting models range from full control (dedicated servers) to full abstraction (serverless).
Practice
- (Beginner) Use
digornslookupto find theA,AAAAandMXrecords of a domain you use daily.- (Intermediate) Add an entry to your hosts file that maps
myapp.testto127.0.0.1, run the Node server from the client-server topic, and openhttp://myapp.test:3000/greeting. Explain which resolution steps were skipped.- (Interview) "You moved a website to a new server and updated its DNS record, but some users still reach the old server hours later. Why, and how could you have avoided it?" Hint: every resolver and browser along the way cached the old answer for a certain amount of time.
Browser Architecture and Rendering Pipeline
Knowing how a browser turns a URL into pixels explains a lot of practical behaviour: why a script in the <head> can delay the whole page, why some CSS changes are cheap and others expensive, and why one crashed tab no longer takes down the whole browser.
Components of a browser
+-------------------------------------------------------------+
| User Interface (address bar, tabs, back/forward buttons) |
+-------------------------------------------------------------+
| Browser Engine (coordinates UI and rendering engine) |
+-------------------------------------------------------------+
| Rendering Engine (HTML/CSS -> pixels: Blink, Gecko, WebKit)|
+----------------+------------------+-------------------------+
| Networking | JavaScript | Data storage |
| (HTTP, cache) | Engine (V8, | (cookies, IndexedDB, |
| | SpiderMonkey) | cache storage) |
+----------------+------------------+-------------------------+
Multi-process architecture. Modern browsers run separate operating-system processes for different jobs. In Chromium-based browsers:
| Process | Responsibility |
|---|---|
| Browser process | UI, navigation, coordination, file and network permissions |
| Renderer processes | Parse, lay out and render pages and run JavaScript; usually one per site, sandboxed |
| GPU process | Compositing and drawing on the graphics card |
| Network service | All network requests |
| Utility / plugin processes | Audio, extensions and other isolated services |
The reason is stability and security: if one site's renderer crashes or is compromised, the browser process and other sites survive. Site isolation keeps pages from different sites in different processes, which also protects against side-channel attacks such as Spectre.
From URL to pixels
Navigation first fetches the document: DNS lookup, TCP (or QUIC) connection, TLS handshake, HTTP request, response. Then the critical rendering path begins:
HTML bytes ──parse──> DOM tree ─────────┐
├──> Render tree ──> Layout ──> Paint ──> Composite ──> pixels
CSS bytes ──parse──> CSSOM tree ───────┘ (visible nodes (geometry: (draw (combine
+ computed size and commands layers
JS ──────executes──> can modify DOM and CSSOM styles) position) per layer) on the GPU)
- Parse HTML into the DOM. The parser reads the markup token by token and builds the Document Object Model, a tree of nodes.
- Parse CSS into the CSSOM. Stylesheets become the CSS Object Model with all rules resolved.
- Build the render tree. DOM and CSSOM are combined. Only visible nodes are kept:
display: noneelements and<head>content are left out. - Layout (reflow). The engine calculates the exact position and size of every box for the current viewport.
- Paint. Each box is turned into drawing instructions (text, colours, borders, shadows, images).
- Composite. Painted layers are combined, usually on the GPU, into the final frame. Changes to
transformandopacityoften need only this step, which is why they animate smoothly.
Blocking resources
- CSS is render-blocking. The browser won't paint until the CSSOM is ready, to avoid showing unstyled content.
- Classic scripts are parser-blocking. A plain
<script src>stops HTML parsing while it downloads and runs, because the script might calldocument.writeor read the DOM built so far. It also waits for any pending CSS, since it might read computed styles.
| Script tag | Download | Execution | Order kept? |
|---|---|---|---|
<script src> | Blocks parsing | Immediately when downloaded | Yes |
<script src async> | In parallel | As soon as it arrives, pausing parsing | No |
<script src defer> | In parallel | After parsing finishes, before DOMContentLoaded | Yes |
<script type="module"> | In parallel | Deferred by default | Yes |
Example: resource loading in practice
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- Render-blocking: nothing is painted until this CSSOM is built -->
<link rel="stylesheet" href="main.css">
<!-- Independent script: downloads in parallel and runs whenever it arrives -->
<script src="analytics.js" async></script>
<!-- App code: downloads in parallel and runs in order after the DOM is parsed -->
<script src="app.js" defer></script>
</head>
<body>
<h1>Hello</h1>
<!-- Parser-blocking: parsing stops here until legacy.js is downloaded AND executed -->
<script src="legacy.js"></script>
<p>This paragraph is parsed only after legacy.js has run.</p>
</body>
</html>
JavaScript that reads layout right after changing styles forces the browser to recalculate layout synchronously. This is called forced reflow and is covered in detail in Chapter 6:
const box = document.querySelector('.box');
box.style.width = '300px'; // invalidates layout
const h = box.offsetHeight; // forces an immediate layout to return an up-to-date value
Key Takeaways
- A browser is made of a UI, a browser engine, a rendering engine, a JavaScript engine, networking and storage.
- Multi-process, sandboxed architectures isolate sites from each other for stability and security.
- The rendering pipeline is: DOM + CSSOM, then render tree, layout, paint, composite.
- CSS blocks rendering and classic scripts block parsing. Use
defer,asyncor modules to avoid stalls. - Changes that skip layout and paint (such as
transformandopacity) are the cheapest to animate.
Practice
- (Beginner) Open the Chrome Task Manager (Shift+Esc on Windows/Linux) with several tabs open and count the renderer processes. Explain what you see.
- (Intermediate) Build a page with a slow script (simulate it with a server delay) placed in the
<head>. Measure when the first paint happens in the Performance panel, then switch the tag todeferand measure again.- (Interview) "What happens, as completely as you can explain it, when you type a URL into the address bar and press Enter?" Hint: structure the answer by layer: URL parsing, DNS, TCP/TLS, HTTP, then each step of the rendering pipeline, and mention where caches can short-circuit the process.
<a id="12-evolution-of-the-web"></a>
1.2 Evolution of the Web
Web architecture has changed a great deal since the early 1990s. Each shift solved a concrete problem of the generation before it and brought new trade-offs that still shape design decisions today.
Static Pages and CGI
The first Web was a library of documents. A static web server maps the path in a URL to a file on disk and sends that file back unchanged. Every visitor gets exactly the same bytes.
GET /docs/intro.html ──> web server ──> reads /var/www/docs/intro.html ──> sends file
Static pages are fast, easy to cache and have almost no attack surface. They have one obvious limitation: they cannot react to the user. A search box, a guestbook or a page that greets you by name needs code that runs per request.
CGI (Common Gateway Interface), introduced by NCSA in 1993, was the first standard answer. It defines how a web server hands a request to an external program and gets a response back:
- The server receives a request for a URL mapped to a script (traditionally under
/cgi-bin/). - It starts a new operating-system process for that program.
- It passes request details as environment variables (
REQUEST_METHOD,QUERY_STRING,CONTENT_TYPE,CONTENT_LENGTH,REMOTE_ADDR...) and the request body on standard input. - The program writes HTTP headers, a blank line and the body to standard output.
- The server sends that output to the client and the process exits.
Browser ──HTTP──> Web server ──fork/exec──> CGI program (Perl, C, Python, shell...)
│ env vars + stdin ──> │
│ <── stdout (headers + body)
Browser <──HTTP── Web server process exits
Analogy: CGI is like a shop that hires, trains and fires a new employee for every single customer. It works, but most of the effort goes into the hiring.
Because CGI only depends on environment variables and standard streams, any language could be used, and Perl became the favourite. The cost of starting one process per request limited throughput, which led to FastCGI (persistent worker processes), in-server modules such as mod_perl and mod_php, and eventually long-running application servers.
Example: a CGI script
#!/usr/bin/env python3
# hello.py — a CGI script: the web server runs this once per request
import html
import os
from urllib.parse import parse_qs
params = parse_qs(os.environ.get("QUERY_STRING", "")) # e.g. "name=Ada" -> {'name': ['Ada']}
name = params.get("name", ["stranger"])[0]
print("Content-Type: text/html; charset=utf-8") # HTTP header written to stdout
print() # blank line = end of headers
print(f"<h1>Hello, {html.escape(name)}!</h1>") # escape user input to prevent XSS
Requesting /cgi-bin/hello.py?name=Ada returns <h1>Hello, Ada!</h1>. The call to html.escape matters: without it, ?name=<script>...</script> would inject code into the page. This class of vulnerability, cross-site scripting, has existed since the first dynamic page.
Key Takeaways
- Static servers map URLs to files. They are fast, cacheable and secure, but cannot personalise anything.
- CGI was the first standard interface for running programs per request, using environment variables, stdin and stdout.
- One process per request made CGI simple and language-neutral, but expensive at scale.
- FastCGI, server modules and persistent application servers replaced it by keeping code loaded between requests.
- Any dynamic output that includes user input must be escaped.
Practice
- (Beginner) Create a three-page static site with relative links between the pages and serve it with
python3 -m http.server. Check theContent-Typeof each response.- (Intermediate) Suppose starting a process costs 5 ms of CPU time and the script's actual work takes 1 ms. Estimate how many requests per second one CPU core can handle with CGI, compared with a persistent worker. What does this tell you about where the time goes?
- (Interview) "Why did CGI fall out of favour, and what replaced it?" Hint: compare the lifecycle of a CGI process with that of a long-running application server, and think about database connections and in-memory caches.
Dynamic Server-Rendered Applications
The next generation kept the program running between requests and made it easier to produce HTML from data. Server-side languages and frameworks such as PHP (1995), ASP and JSP, and later Ruby on Rails (2004), Django (2005) and Express with template engines, became the standard way to build web applications.
In a server-rendered application the server does all the work:
Browser Server Database
│ GET /products │ │
│ ─────────────────────────>│ controller: handle route │
│ │ ───── SELECT * FROM products ──────>│
│ │ <──────────── rows ─────────────────│
│ │ view: fill template with data │
│ <──── complete HTML ──────│ │
│ (full page reload) │ │
│ user clicks a link -> the whole cycle repeats, new page │
Most frameworks organise this code with the Model-View-Controller (MVC) pattern:
| Component | Responsibility | Example |
|---|---|---|
| Model | Data and business rules, usually backed by a database | Product, Order |
| View | Presentation: templates that turn data into HTML | products.ejs |
| Controller | Receives a request, uses models, chooses a view | GET /products handler |
Templates mix static HTML with placeholders and simple control flow (loops, conditionals). The server fills them in for every request. Sessions, usually identified by a cookie, let the server remember a logged-in user across stateless HTTP requests.
Strengths: complete HTML arrives in the first response, which is good for search engines and for first paint. The mental model is simple, pages work without JavaScript, and secrets and business logic stay on the server.
Weaknesses: every interaction reloads the whole page, even when one number changes. The server renders everything, which costs CPU. Rich interactions such as drag and drop or live updates are awkward.
Example: Express with an EJS template
// app.js — controller and (in-memory) model
const express = require('express');
const app = express();
app.set('view engine', 'ejs'); // templates are loaded from ./views
const products = [
{ id: 1, name: 'Keyboard', price: 49.9 },
{ id: 2, name: 'Mouse', price: 19.9 },
];
app.get('/products', (req, res) => {
// In a real app this would be a database query
res.render('products', { title: 'Catalog', products }); // view name + data
});
app.listen(3000);
<!-- views/products.ejs — the view -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title><%= title %></title></head>
<body>
<h1><%= title %></h1> <!-- <%= %> prints an HTML-escaped value -->
<ul>
<% products.forEach(p => { %> <!-- <% %> runs JavaScript, prints nothing -->
<li><%= p.name %>: EUR <%= p.price.toFixed(2) %></li>
<% }) %>
</ul>
</body>
</html>
A common pattern in server-rendered apps is Post/Redirect/Get (PRG): after a form POST, the server answers with a 303 See Other redirect to a GET page, so refreshing the result page does not submit the form again.
Key Takeaways
- Server-rendered applications build complete HTML on the server for every request, often following MVC.
- Template engines combine HTML markup with data and simple logic.
- Each interaction causes a full page reload, which is simple and SEO-friendly but less interactive.
- Sessions add continuity on top of stateless HTTP.
- PRG prevents duplicate form submissions.
Practice
- (Beginner) Add a route
/products/:idto the example that renders a detail page, and return a 404 page for unknown ids.- (Intermediate) Add a form that creates a new product with
POST /products, then apply the Post/Redirect/Get pattern. Check what happens on refresh with and without the redirect.- (Interview) "What is the Post/Redirect/Get pattern and what problem does it solve?" Hint: think about what the browser does when the user presses F5 on a page that was the result of a POST.
AJAX and Web 2.0
Full page reloads made web applications feel clumsy next to desktop software. The fix was to let JavaScript fetch data in the background and update only the part of the page that changed.
Microsoft created the core building block, an object later standardised as XMLHttpRequest (XHR), around 1999 for Outlook Web Access. Other browsers adopted it, and Gmail (2004) and Google Maps (2005) showed what it could do: smooth, app-like interfaces in a browser tab. In 2005 Jesse James Garrett named the technique AJAX, Asynchronous JavaScript and XML. In practice JSON soon replaced XML as the data format.
Classic model AJAX model
------------- ----------
click -> request page click -> JS sends background request
<- whole new HTML page <- small JSON payload
browser repaints everything JS updates one part of the DOM
(blank flash, scroll position lost) (page stays interactive)
Analogy: in the classic model, changing one item in your restaurant order means the waiter clears the table and brings a fresh one with everything re-served. With AJAX, the waiter just swaps the one dish.
Web 2.0, a term popularised by Tim O'Reilly in 2004, described the wider cultural and technical shift this enabled:
- User-generated content: blogs, wikis, social networks, video sharing.
- The web as a platform: applications delivered through the browser instead of installed.
- Rich interfaces: autocomplete, infinite scrolling, live updates.
- Open APIs and mashups: combining data from several services (for example, putting listings on a map).
AJAX also caused new problems. The back button and bookmarks no longer matched what was on screen, search engines could not see content loaded by scripts, and keeping UI state in sync with the server became the developer's job. Libraries such as jQuery (2006) hid browser differences and made AJAX easy to use (Chapter 8).
Example: AJAX then and now
// Classic AJAX with XMLHttpRequest (circa 2005)
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/comments?post=42'); // configure the request (async by default)
xhr.onload = () => {
if (xhr.status === 200) {
const comments = JSON.parse(xhr.responseText);
renderComments(comments); // update only the comments section
}
};
xhr.onerror = () => console.error('Network error');
xhr.send();
// The same request with the modern Fetch API and async/await
async function loadComments(postId) {
const res = await fetch(`/api/comments?post=${encodeURIComponent(postId)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
renderComments(await res.json());
}
function renderComments(comments) {
const list = document.querySelector('#comments');
list.replaceChildren(
...comments.map((c) => {
const li = document.createElement('li');
li.textContent = `${c.author}: ${c.text}`; // textContent avoids HTML injection
return li;
})
);
}
Key Takeaways
- AJAX lets JavaScript exchange data with the server without reloading the page.
- XMLHttpRequest made it possible; the Fetch API is the modern replacement.
- JSON replaced XML as the usual payload format.
- Web 2.0 described the move to interactive, social, API-driven web applications.
- AJAX made history, bookmarking, SEO and client state the developer's responsibility.
Practice
- (Beginner) Build a "Load more" button that fetches the next page of items from a JSON endpoint and appends them to a list without reloading.
- (Intermediate) Rewrite the XHR example as a function that returns a Promise, so callers can use
awaitwith it. Handle network errors and non-2xx status codes separately.- (Interview) "What problems did AJAX introduce that traditional server-rendered pages did not have?" Hint: think about URLs, browser history, search engine crawlers, screen readers and where the "truth" about the UI state lives.
Single Page Applications
If AJAX can update part of a page, why not let JavaScript handle the whole interface? A Single Page Application (SPA) loads one HTML shell and a JavaScript bundle once. From then on the client renders every view, fetches only data (usually JSON) and changes the URL without asking the server for new pages.
First visit: GET / -> index.html (almost empty) + app.js (large) + styles
JS boots, fetches GET /api/... -> JSON, renders the view
Navigation: click "About" -> JS intercepts the click
-> history.pushState('/about') (URL changes, no reload)
-> renders the About view, fetches data if needed
SPAs rely on three ingredients:
- Client-side rendering: JavaScript builds and updates the DOM.
- An API back end: the server mainly exposes data endpoints (REST, GraphQL).
- Client-side routing: the History API (
pushState,popstate) keeps URLs, bookmarks and the back button working.
Frameworks made this approach mainstream: Backbone.js and AngularJS (2010), Ember, React (2013), Vue (2014), Angular (2016) and Svelte.
| Advantages | Disadvantages |
|---|---|
| App-like, instant navigation after the first load | Slow first load: large bundle, blank page until JS runs |
| Server only serves data and is easier to reuse for mobile apps | Content is invisible to crawlers that don't run JS |
| Clear split between front-end and back-end teams | Complex client state, memory leaks in long sessions |
| Rich interactions and offline capability | Accessibility work: focus and announcements on route change |
| Requires JavaScript; fragile on slow devices |
Example: a minimal client-side router
<!-- index.html — the single page -->
<nav>
<a href="/" data-link>Home</a>
<a href="/about" data-link>About</a>
</nav>
<main id="app"></main>
<script type="module" src="/router.js"></script>
// router.js — maps paths to view functions
const routes = {
'/': () => '<h1>Home</h1><p>Welcome!</p>',
'/about': () => '<h1>About</h1><p>A tiny SPA.</p>',
};
function render(path) {
const view = routes[path] ?? (() => '<h1>404</h1><p>Page not found.</p>');
document.getElementById('app').innerHTML = view(); // static templates only, so innerHTML is safe here
}
// Intercept clicks on internal links (event delegation)
document.addEventListener('click', (e) => {
const link = e.target.closest('a[data-link]');
if (!link) return;
e.preventDefault(); // cancel the normal full-page navigation
history.pushState(null, '', link.href); // update the URL without contacting the server
render(location.pathname);
});
// Back/forward buttons fire popstate; re-render for the restored URL
window.addEventListener('popstate', () => render(location.pathname));
render(location.pathname); // render the view for the URL the user arrived on
For deep links to work, the server must answer every application route (/about, /users/42...) with index.html. Otherwise reloading /about asks the server for a file that doesn't exist.
Key Takeaways
- An SPA loads once and then renders all views on the client, fetching only data.
- Client-side routing with the History API keeps URLs meaningful without reloading the page.
- SPAs feel fast after loading, but pay with a slower first load, SEO difficulties and more client complexity.
- The server needs a fallback rule that serves
index.htmlfor unknown application paths.
Practice
- (Beginner) Add a
/contactroute to the router above and a link to it in the navigation.- (Intermediate) Serve the SPA with a small Express server that returns
index.htmlfor every non-file path. Then move focus to the new<h1>after each navigation so screen-reader users notice the change.- (Interview) "In a deployed SPA, refreshing the page at
/dashboardgives a 404, but clicking a link to it works. Why, and how do you fix it?" Hint: compare who handles the URL in each case, the browser-side router or the web server.
Multi-Page vs Single Page Architectures
The choice between a Multi-Page Application (MPA), where the server renders a new document for each navigation, and a Single Page Application (SPA) is one of the first architectural decisions in a web project. Neither is better in general. Each fits different needs.
MPA: [page A] --click--> request --> server renders --> [page B] (new document)
SPA: [shell + view A] --click--> JS swaps view --> [shell + view B] (same document)
| Criterion | MPA | SPA |
|---|---|---|
| Navigation | Full document load per page | View swap in JavaScript |
| Initial load | Fast: HTML arrives ready | Slower: JS must download and run |
| Later navigations | Each one is a round trip | Very fast once loaded |
| SEO | Excellent by default | Needs SSR/prerendering to be reliable |
| Server role | Renders HTML, holds logic | Serves static files and a JSON API |
| State | Mostly on the server (sessions) | Mostly on the client (memory, stores) |
| JavaScript dependency | Optional, used for enhancement | Required |
| Complexity | Lower | Higher (routing, state, build tooling) |
| Best for | Content sites, e-commerce, blogs, docs | Highly interactive tools, dashboards, editors |
Analogy: an MPA is a printed catalogue where each page is a fresh sheet. An SPA is a tablet app where the frame stays and only the content changes.
The middle ground. Modern practice often mixes the two:
- MPA with progressive enhancement: server-rendered pages with small JavaScript "sprinkles" or interactive islands.
- HTML-over-the-wire libraries (htmx, Hotwire/Turbo): the server returns HTML fragments that are swapped into the page, giving an SPA feel without a client-side framework.
- View Transitions API: smooth animated transitions between ordinary MPA pages.
- Meta-frameworks (next topics): render each route on the server and then continue as an SPA.
Example: the same "load more" feature, three ways
<!-- 1. Pure MPA: a normal link, the server renders page 2 entirely -->
<a href="/comments?page=2">Next page</a>
<!-- 2. Hybrid with htmx: the server returns an HTML fragment that is appended -->
<button hx-get="/comments?page=2" hx-target="#comments" hx-swap="beforeend">
Load more
</button>
<ul id="comments"><!-- server-rendered first page --></ul>
<!-- 3. SPA: client code fetches JSON and renders it (see the AJAX topic) -->
<button id="more">Load more</button>
<script type="module">
document.querySelector('#more').addEventListener('click', () => loadComments(2));
</script>
Key Takeaways
- MPAs render each page on the server. SPAs render views on the client after one initial load.
- MPAs win on first load, SEO and simplicity. SPAs win on interactivity and repeated navigation.
- Choose based on content type, interactivity, audience devices and team skills, not on trends.
- Hybrid approaches (islands, htmx, View Transitions, meta-frameworks) narrow the gap between the two.
Practice
- (Beginner) Classify these as better suited to an MPA, an SPA or a hybrid, and justify each: a news site, an online design tool, an e-commerce store, an internal admin dashboard, a personal blog.
- (Intermediate) Implement the "load more" feature with htmx (or plain fetch returning HTML) against a small Express server, and compare the bytes transferred with a JSON-based version.
- (Interview) "A client needs a public marketing site and a complex logged-in dashboard. Which architecture would you choose?" Hint: the two parts have different audiences, SEO needs and interactivity, and nothing forces you to use one architecture for both.
Server-Side Rendering and Static Site Generation
SPAs brought interactivity but also brought back an old problem: the first response is an empty shell, so users and crawlers see nothing until JavaScript runs. Server-Side Rendering (SSR) and Static Site Generation (SSG) produce real HTML up front while keeping the component-based development model of SPAs.
The rendering strategies
| Strategy | When HTML is produced | Where | Content freshness | Typical use |
|---|---|---|---|---|
| CSR (client-side rendering) | At runtime, in the browser | Client | Always fresh | Dashboards behind a login |
| SSR (server-side rendering) | On every request | Server | Always fresh | Personalised or frequently changing pages |
| SSG (static site generation) | Once, at build time | Build machine, served from a CDN | Stale until the next build | Docs, blogs, marketing pages |
| ISR (incremental static regeneration) | At build time, then regenerated in the background | Server/CDN | Refreshed periodically or on demand | Large catalogues, news |
CSR: request ─> empty shell ─> download JS ─> fetch data ─> render ─────────> visible + interactive
SSR: request ─> server fetches data + renders ─> HTML visible ─> JS loads ─> hydrated (interactive)
SSG: (build: render all pages) ... request ─> CDN returns HTML instantly ─> JS loads ─> hydrated
Hydration. With SSR and SSG the browser receives finished HTML, but the HTML has no event handlers yet. The client then downloads the same components and hydrates the page: it walks the existing DOM and attaches state and listeners instead of re-creating it. Until hydration finishes, the page looks ready but may not respond to clicks.
Analogy: SSR delivers a fully assembled piece of furniture instead of a flat pack (CSR). You can see and use it immediately, but the electrician (hydration) still has to come and connect the lights.
Newer techniques reduce the cost of hydration:
- Streaming SSR: send HTML in chunks as data becomes available.
- Partial hydration / islands (Astro): hydrate only the interactive components.
- React Server Components: components that run only on the server and send no JavaScript to the client.
Example: SSR with React and Express (simplified)
// server.jsx — renders the React tree to HTML on each request
import express from 'express';
import { renderToString } from 'react-dom/server';
import App from './App.jsx';
const app = express();
app.use(express.static('public')); // serves client.js
app.get('/', async (req, res) => {
const products = await loadProducts(); // data is fetched on the server
const html = renderToString(<App products={products} />); // component tree -> HTML string
res.send(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Shop</title></head>
<body>
<div id="root">${html}</div>
<!-- Send the same data so the client renders identical markup; escape it in production -->
<script>window.__DATA__ = ${JSON.stringify(products)}</script>
<script type="module" src="/client.js"></script>
</body>
</html>`);
});
app.listen(3000);
// client.jsx — hydration: attach React to the server-rendered DOM
import { hydrateRoot } from 'react-dom/client';
import App from './App.jsx';
hydrateRoot(document.getElementById('root'), <App products={window.__DATA__} />);
If the client renders different markup than the server did (for example because it used Date.now() or window.innerWidth), React reports a hydration mismatch.
Example: a tiny static site generator
// build.mjs — renders every page once, at build time
import { mkdir, writeFile } from 'node:fs/promises';
const posts = [
{ slug: 'hello-world', title: 'Hello World', body: 'My first post.' },
{ slug: 'ssg-explained', title: 'SSG Explained', body: 'HTML built ahead of time.' },
];
await mkdir('dist', { recursive: true });
for (const post of posts) {
const html = `<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>${post.title}</title></head>
<body><article><h1>${post.title}</h1><p>${post.body}</p></article></body></html>`;
await writeFile(`dist/${post.slug}.html`, html); // upload dist/ to any static host or CDN
}
Key Takeaways
- CSR renders in the browser, SSR renders per request on the server, SSG renders once at build time, and ISR regenerates static pages over time.
- SSR and SSG improve first paint and SEO by sending real HTML.
- Hydration makes server-rendered HTML interactive, at the cost of downloading and running the component code.
- Server and client must produce the same markup to avoid hydration mismatches.
- Pick the strategy per page, based on how fresh and how personalised the content must be.
Practice
- (Beginner) Choose a rendering strategy (CSR, SSR, SSG or ISR) for: a documentation page, a user's account settings, a product page with live stock levels, a company "About" page.
- (Intermediate) Open "View Page Source" (not DevTools Elements) on a known SPA and on a server-rendered site. Compare what is in the raw HTML and explain what a crawler that does not run JavaScript would see.
- (Interview) "What is hydration, and what causes a hydration mismatch?" Hint: think about two renders of the same component in two environments, and which values can differ between server and browser.
Full-Stack Frameworks and Isomorphic JavaScript
Before Node.js (2009), web applications were written in two languages: JavaScript in the browser and PHP, Java, Ruby or Python on the server. Validation rules, data models and rendering logic were often written twice. With JavaScript on both sides, the same code can run in both places.
Isomorphic (or universal) JavaScript is code that runs unchanged on the server and in the browser. Typical shared pieces:
- Validation rules (the same checks give instant feedback in the form and are enforced on the server).
- Data types and models (especially with TypeScript).
- UI components (rendered to HTML on the server, hydrated in the browser).
- Utility functions (formatting, date handling).
Full-stack frameworks (also called meta-frameworks) build on this idea. They combine routing, server rendering, data loading, API endpoints and bundling into one project:
| Framework | UI library | Notable features |
|---|---|---|
| Next.js | React | App Router, Server Components, SSR/SSG/ISR per route, server actions |
| Remix / React Router v7 | React | Loaders and actions per route, built on web standards |
| Nuxt | Vue | Auto-imports, file-based routing, hybrid rendering |
| SvelteKit | Svelte | Load functions, form actions, adapters for many hosts |
| Astro | Any (islands) | Content-focused, ships zero JS by default |
| Angular SSR | Angular | Server rendering and hydration for Angular apps |
Most of them use file-based routing, where the folder structure defines the URLs:
app/ (Next.js App Router)
├── layout.jsx -> shared shell for all pages
├── page.jsx -> /
├── blog/
│ ├── page.jsx -> /blog
│ └── [slug]/
│ └── page.jsx -> /blog/:slug (dynamic segment)
└── api/
└── posts/
└── route.js -> /api/posts (server-only endpoint)
Pitfalls of running code in two environments
| Problem | Cause | Fix |
|---|---|---|
window is not defined | Browser globals don't exist on the server | Guard with typeof window !== 'undefined' or run the code after mount |
| Leaked secrets | Server-only code or env variables included in the client bundle | Keep secrets in server-only modules; only expose explicitly public variables |
| Hydration mismatch | Different output on server and client | Avoid random values, time or viewport reads during render |
| Heavy client bundles | Server-side libraries imported into shared code | Keep clear module boundaries |
Example: shared validation and a server component
// shared/validateEmail.js — imported by both the browser form and the API route
export function validateEmail(email) {
return typeof email === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// app/blog/[slug]/page.jsx — a Next.js Server Component: runs only on the server
import { db } from '@/lib/db'; // database client; never shipped to the browser
export default async function PostPage({ params }) {
const { slug } = await params; // route parameter from the [slug] folder
const post = await db.posts.findOne({ slug }); // direct DB access, no API round trip
if (!post) return <p>Post not found.</p>;
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
// Guarding browser-only code in shared modules
export function getSavedTheme() {
if (typeof window === 'undefined') return 'light'; // on the server: safe default
return localStorage.getItem('theme') ?? 'light'; // in the browser: read the preference
}
Key Takeaways
- Isomorphic JavaScript runs the same code on server and client, which reduces duplication.
- Full-stack frameworks combine routing, rendering strategies, data loading and APIs in one project.
- File-based routing maps folders and files directly to URLs.
- Keep the two environments in mind: browser globals, secrets and deterministic rendering.
Practice
- (Beginner) Explain what would go wrong if a shared module ran
const theme = localStorage.getItem('theme')at the top level in an SSR app, and fix it.- (Intermediate) Create a shared validation module for a sign-up form (email, password length, matching passwords). Use it in a browser form for instant feedback and in an Express route for enforcement.
- (Interview) "In an isomorphic application, how do you make sure a server-only secret, like a database password, never ends up in the client bundle?" Hint: look at how frameworks decide which environment variables are public, and at markers or conventions that make the bundler fail when a server-only module is imported from client code.
The MEAN, MERN and JAMstack Stacks
A stack is a set of technologies chosen to work together across every tier of an application. Named stacks give teams a shared vocabulary and a proven starting point. The classic example was LAMP: Linux, Apache, MySQL, PHP.
MEAN and MERN put JavaScript on every tier:
| Letter | MEAN | MERN | MEVN | Tier |
|---|---|---|---|---|
| M | MongoDB | MongoDB | MongoDB | Database (JSON-like documents) |
| E | Express | Express | Express | Web framework / API |
| A / R / V | Angular | React | Vue | Front end (SPA) |
| N | Node.js | Node.js | Node.js | Server runtime |
+---------------------+ JSON over HTTP +----------------------+ BSON +-------------+
| React / Angular | <----------------> | Express on Node.js | <------> | MongoDB |
| (browser, SPA) | fetch('/api') | (REST API) | driver | (documents) |
+---------------------+ +----------------------+ +-------------+
JavaScript JavaScript JSON-like data
The main appeal is one language and one data format end to end. A JavaScript object in the UI becomes JSON on the wire and a document in the database with almost no translation. The downsides are that MongoDB is a poor fit for highly relational data, and a single-language team may miss better tools in other ecosystems.
JAMstack (JavaScript, APIs, Markup), a term coined at Netlify around 2015, describes a different architecture rather than a list of products:
- Markup is prebuilt (SSG) and served from a CDN.
- JavaScript in the browser adds the dynamic behaviour.
- APIs provide everything server-side: serverless functions, a headless CMS, authentication, payments and search services.
Build time: Git push ──> build (SSG) ──> HTML/CSS/JS ──> deployed to CDN edges
Run time: Browser ──> CDN (static files, very fast)
└──JS──> APIs: serverless functions, headless CMS, auth, payments
The label is used less today and has been absorbed into terms like "composable architecture", but the ideas (prebuilding, CDN delivery, outsourced services) are now standard practice.
| Aspect | LAMP | MERN | JAMstack |
|---|---|---|---|
| Rendering | Server, per request | Client (SPA) | Build time, plus client JS |
| Language(s) | PHP + SQL | JavaScript everywhere | Any; mostly JavaScript |
| Data store | Relational (MySQL) | Document (MongoDB) | Any, behind APIs |
| Hosting | One server | Node server + database | CDN + managed services |
| Scaling | Scale the server | Scale API and database | CDN scales automatically |
Example: one feature across the MERN tiers
// server.js — Express API reading from MongoDB
import express from 'express';
import { MongoClient } from 'mongodb';
const client = await MongoClient.connect(process.env.MONGO_URL);
const todos = client.db('app').collection('todos');
const app = express();
app.get('/api/todos', async (req, res) => {
const open = await todos.find({ done: false }).toArray(); // documents from MongoDB
res.json(open); // sent as JSON
});
app.listen(3000);
// TodoList.jsx — React component consuming the API
import { useEffect, useState } from 'react';
export function TodoList() {
const [todos, setTodos] = useState([]);
useEffect(() => {
fetch('/api/todos')
.then((r) => r.json())
.then(setTodos); // the same objects, now in the UI
}, []); // [] = run once after the first render
return (
<ul>
{todos.map((t) => <li key={t._id}>{t.title}</li>)}
</ul>
);
}
Key Takeaways
- A stack is a coordinated set of technologies covering database, server and front end.
- MEAN, MERN and MEVN use JavaScript and JSON on every tier and differ only in the front-end framework.
- JAMstack serves prebuilt markup from CDNs and moves dynamic features to APIs and serverless functions.
- Choose a stack based on your data's shape, team skills and hosting model, not on the acronym.
Practice
- (Beginner) Name each technology in MEAN and MERN and the tier it belongs to.
- (Intermediate) Sketch a blog with comments twice, once as MERN and once as JAMstack. For each, say where posts are stored, where HTML is generated and how comments are submitted.
- (Interview) "When would you not pick MongoDB for a MERN-style application?" Hint: think about data with many relationships, joins, strict schemas and multi-record transactions, such as an accounting system.
<a id="13-standards-and-development-environment"></a>
1.3 Standards and Development Environment
The Web works because independent vendors implement shared specifications. This subchapter covers who writes those specifications, how browsers implement them, and the tools developers use to build and debug against them.
W3C, WHATWG and ECMA Standards
In the late 1990s Netscape and Microsoft competed by adding incompatible, proprietary features to their browsers. This period is known as the browser wars. Developers had to build separate versions of their sites and display badges like "Best viewed in Internet Explorer". Open standards fix this: a public specification that every vendor implements, so one codebase works everywhere.
Several organisations share the work:
| Organisation | Founded | Main responsibilities | Style |
|---|---|---|---|
| W3C (World Wide Web Consortium) | 1994, by Tim Berners-Lee | CSS, accessibility (WCAG, WAI-ARIA), SVG, many Web APIs | Versioned Recommendations |
| WHATWG (Web Hypertext Application Technology Working Group) | 2004, by Apple, Mozilla, Opera | HTML, DOM, Fetch, URL, Streams, Encoding | Living Standards |
| Ecma International, TC39 | Ecma 1961; ECMAScript since 1997 | ECMAScript, the JavaScript language (ECMA-262) | Yearly editions |
| IETF (Internet Engineering Task Force) | 1986 | HTTP, TLS, URIs, WebSocket protocol (RFCs) | RFC documents |
| Khronos Group | 2000 | WebGL, WebGPU shading languages (with W3C) | Versioned specifications |
W3C develops specifications through a maturity process:
Working Draft (WD) ──> Candidate Recommendation (CR) ──> Proposed Recommendation (PR) ──> W3C Recommendation (REC)
ideas, feedback feature-complete, needs final review official standard
implementations to prove it
WHATWG was formed when browser vendors disagreed with the W3C's plan to replace HTML with the strict XML-based XHTML 2.0. WHATWG's work became HTML5. Its specifications are Living Standards: one continuously updated document instead of numbered versions, reflecting what browsers actually implement. Since a 2019 agreement, WHATWG maintains the HTML and DOM standards and W3C endorses periodic snapshots of them.
TC39 evolves JavaScript. Since ES2015 (also called ES6) a new edition comes out every June, containing whatever features are ready. Every feature goes through a staged process:
| Stage | Meaning |
|---|---|
| 0 | Idea, not yet formally considered |
| 1 | Proposal: the committee agrees the problem is worth exploring |
| 2 | Draft: syntax and semantics described in spec language |
| 2.7 | Approved in principle; waiting for tests and implementation experience |
| 3 | Recommended for implementation; changes only for critical issues |
| 4 | Finished: included in the next yearly edition |
Analogy: standards bodies are like committees that write building codes. Builders (browser vendors) sit on the committees and must build to the codes, so that any appliance (website) can plug into any house (browser).
Example: ECMAScript features by edition
// ES2015 (ES6): let/const, arrow functions, classes, modules, promises, template literals
const square = (x) => x * x;
// ES2017: async/await
const data = await fetch('/api').then((r) => r.json());
// ES2020: optional chaining and nullish coalescing
const city = user?.address?.city ?? 'Unknown';
// ES2022: class private fields, top-level await, Array.prototype.at
class Counter {
#count = 0; // truly private field
increment() { return ++this.#count; }
}
[10, 20, 30].at(-1); // 30
// ES2023: non-mutating array methods
const sorted = [3, 1, 2].toSorted(); // [1, 2, 3], original array unchanged
// ES2024: grouping
Object.groupBy(['apple', 'avocado', 'banana'], (w) => w[0]); // { a: [...], b: [...] }
// ES2025: Set methods and iterator helpers
new Set([1, 2, 3]).intersection(new Set([2, 3, 4])); // Set {2, 3}
[1, 2, 3, 4].values().filter((n) => n % 2 === 0).toArray(); // [2, 4]
The specification text itself is published at tc39.es/ecma262. Proposals and their current stages are listed in the tc39/proposals repository on GitHub.
Key Takeaways
- Open standards replaced the incompatible proprietary features of the browser wars.
- W3C handles CSS, accessibility and many APIs; WHATWG maintains HTML and DOM as Living Standards.
- TC39 at Ecma International evolves JavaScript with yearly editions and a stage 0 to 4 proposal process.
- The IETF standardises the network protocols the Web runs on, such as HTTP and TLS.
- MDN Web Docs is the practical reference; the specifications are the authoritative source.
Practice
- (Beginner) For each technology, name the organisation that standardises it: HTML, CSS Grid,
fetch(),async/await, HTTP/2, WCAG.- (Intermediate) Pick a TC39 proposal currently at Stage 2 or 3. Summarise in a few sentences what problem it solves, and write a code sample showing how it would be used.
- (Interview) "What is a Living Standard and how does it differ from a versioned specification? What are the pros and cons for developers?" Hint: think about how quickly the document follows browser reality, and what you can cite or rely on at a given moment.
Browser Engines and Compatibility
A browser is a product. An engine is the core software that implements the standards. Each browser combines a rendering engine (HTML, CSS, layout, painting) with a JavaScript engine. Many browsers share the same engines, so the number of real implementations is much smaller than the number of browsers.
| Rendering engine | JS engine | Browsers | Maintainer |
|---|---|---|---|
| Blink | V8 | Chrome, Edge, Opera, Brave, Vivaldi, Samsung Internet | Google and others (Chromium project) |
| Gecko | SpiderMonkey | Firefox | Mozilla |
| WebKit | JavaScriptCore | Safari; nearly all iOS/iPadOS browsers | Apple |
KHTML (KDE, 1998)
└── WebKit (Apple, 2001) ──────────────> Safari
└── Blink (Google fork, 2013) ──> Chrome, Edge (since 2020), Opera, ...
Netscape ──> Gecko (Mozilla) ─────────────> Firefox
Trident (IE) and EdgeHTML (old Edge) ─────> retired
Apple has required all iOS browsers to use WebKit, which is why Chrome and Firefox on iPhone behave like Safari. Since 2024, the EU Digital Markets Act lets browsers in the EU ship their own engines on iOS, but WebKit is still dominant there.
Why compatibility problems happen
- Engines implement new features at different times.
- Specifications can be ambiguous, which leads to small behaviour differences.
- Engines have bugs and occasional non-standard extensions.
- Users run older versions, especially on devices that no longer get updates.
Tools and practices for compatibility
| Tool / practice | Purpose |
|---|---|
| MDN compatibility tables | Per-feature support across browsers and versions |
| Can I Use (caniuse.com) | Support data with global usage percentages |
| Baseline | A label (shown on MDN and web.dev) for features that are Newly available in all major engines or Widely available (stable for about 30 months) |
| Browserslist | One shared config of target browsers, read by Babel, Autoprefixer, bundlers and linters |
| Autoprefixer | Adds vendor prefixes (-webkit-, -moz-) to CSS where your targets need them |
| Cross-browser testing | Real devices, emulators or cloud services such as BrowserStack; Playwright tests all three engines |
| Interop project | Yearly effort in which browser vendors agree to fix the same set of compatibility issues |
Vendor prefixes were historically used to ship experimental features (-webkit-border-radius). Developers relied on them in production, which caused lasting compatibility problems, so engines now ship experiments behind feature flags instead.
Example: target configuration and prefixing
# .browserslistrc — a single source of truth for supported browsers
defaults # a sensible default: >0.5% usage, last 2 versions, Firefox ESR, not dead
not op_mini all # exclude Opera Mini
/* What you write */
.card {
user-select: none;
backdrop-filter: blur(8px);
}
/* What Autoprefixer may output for older WebKit targets in your browserslist */
.card {
-webkit-user-select: none;
user-select: none;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
Key Takeaways
- There are three major engine families: Blink/V8, Gecko/SpiderMonkey and WebKit/JavaScriptCore.
- Most browsers are Chromium-based. Testing only in Chrome means testing only one engine.
- Check feature support with MDN, Can I Use and Baseline before relying on a feature.
- Browserslist defines your targets once. Transpilers and Autoprefixer adapt your code to them.
- Test in all three engines; iOS browsers effectively mean WebKit.
Practice
- (Beginner) Look up the CSS
:has()selector and thePopoverAPI on Can I Use and MDN. Write down their Baseline status and the first browser versions that support them.- (Intermediate) Set up a small project with PostCSS, Autoprefixer and a
.browserslistrc. Compare the output CSS for the targetsdefaultsandlast 1 Chrome version.- (Interview) "Why do all browsers on iOS historically behave like Safari, and what does that mean for testing?" Hint: consider which engine actually renders the page, regardless of the browser's brand.
Browser Developer Tools
Without tools, a web page is a black box: you see the result but not the requests, the DOM, the errors or the timings. Browser developer tools (DevTools) are built into every major browser and let you inspect and change a running page.
Open them with F12, Ctrl+Shift+I (Windows/Linux) or Cmd+Option+I (macOS), or right-click an element and choose Inspect.
| Panel (Chrome name) | What it does | Typical use |
|---|---|---|
| Elements | Live DOM tree and CSS rules, box model, computed styles | Debugging layout and styles, testing CSS changes |
| Console | Logs, errors, and a JavaScript prompt running in the page | Checking values, running quick experiments |
| Sources | Files, breakpoints, step-through debugging, local overrides | Tracing logic errors |
| Network | Every request with headers, payloads, timing waterfall | API debugging, finding slow or failed requests |
| Performance | Records CPU, rendering and paint activity over time | Finding long tasks and jank |
| Memory | Heap snapshots and allocation timelines | Finding memory leaks |
| Application | Cookies, Web Storage, IndexedDB, Cache Storage, service workers | Inspecting client-side state |
| Lighthouse | Automated audits for performance, accessibility, SEO | Quality checks |
| Device toolbar | Emulates screen sizes, touch and network throttling | Responsive testing |
Firefox and Safari have equivalent panels with slightly different names, such as Inspector and Debugger in Firefox.
Debugging techniques
- Breakpoints: line breakpoints, conditional breakpoints (pause only when an expression is true), logpoints (log without changing code), DOM-change breakpoints, fetch/XHR breakpoints and event-listener breakpoints.
- Stepping: step over, into and out of functions, while watching variables and the call stack.
- The
debuggerstatement: pauses execution at that line whenever DevTools is open. - Network tools: disable the cache, throttle to slow connections, block requests, and copy a request as
fetchor cURL to replay it.
Example: Console API beyond console.log
const users = [{ id: 1, name: 'Ada' }, { id: 2, name: 'Linus' }];
console.table(users); // shows the array as a sortable table
console.time('sort'); // start a named timer
const nums = Array.from({ length: 1e6 }, () => Math.random());
nums.sort((a, b) => a - b);
console.timeEnd('sort'); // "sort: 182.4 ms"
console.group('Order #42'); // collapsible, indented group
console.log('items: 3');
console.warn('shipping address missing'); // yellow warning with a stack trace
console.groupEnd();
console.assert(nums.length === 1e6, 'Length changed!'); // logs only when the condition is false
console.trace('How did we get here?'); // prints the current call stack
Example: Console-only helpers (the DevTools Command Line API, not standard JavaScript; they only work when typed into the Console)
$0 // the element currently selected in the Elements panel
$('nav a') // shorthand for document.querySelector (if the page hasn't defined $)
$$('img:not([alt])') // querySelectorAll returned as a real array: images missing alt text
copy(JSON.stringify(users)) // copy any value to the clipboard
monitorEvents(window, 'resize') // Chromium: log every resize event
getEventListeners($0) // Chromium: list the listeners attached to the selected element
Example: pausing in code
function applyDiscount(cart) {
for (const item of cart.items) {
if (item.price < 0) debugger; // pauses only for suspicious data, and only when DevTools is open
item.final = item.price * (1 - cart.discount);
}
}
Remove debugger statements before shipping. Most linters flag them.
Key Takeaways
- DevTools let you inspect the DOM, styles, network traffic, storage, performance and memory of a live page.
- Use the Network panel to debug APIs and the Performance panel to diagnose slowness.
- Breakpoints, especially conditional breakpoints and logpoints, are more effective than scattered
console.logcalls. - The Console API (
table,time,group,assert,trace) and Command Line helpers ($0,$$,copy) speed up investigation.
Practice
- (Beginner) On any website, use the Network panel to find the largest resource and the slowest request. Reload with "Slow 4G" throttling and note how the load time changes.
- (Intermediate) Write a loop over 1000 objects that has a bug for one specific item. Find it using a conditional breakpoint instead of
console.log, then do the same with a logpoint.- (Interview) "A user reports that a page feels slow. Walk me through how you would use DevTools to diagnose the problem." Hint: separate loading performance (Network, Lighthouse) from runtime performance (Performance panel, long tasks, layout thrashing), and reproduce the user's device conditions with throttling.
Feature Detection and Polyfills
Your users run many browsers and versions. You might want to check which browser the visitor is using and branch on that, but this user-agent (UA) sniffing is fragile: UA strings are long, inconsistent and deliberately misleading (almost every browser claims to be "Mozilla"), browsers are reducing the information they contain, and a browser that gains a feature later still gets the fallback.
Feature detection asks the right question instead: does this browser support the feature I need? You test for the API itself and then use it, or choose a fallback.
Analogy: instead of checking a guest's nationality to guess which languages they speak (UA sniffing), you simply ask, "Do you speak Italian?" (feature detection).
Feature detection supports two related strategies:
| Strategy | Starting point | Approach |
|---|---|---|
| Progressive enhancement | A working basic version for everyone | Add better features where they are supported |
| Graceful degradation | The full-featured version | Make sure it still works acceptably when features are missing |
Polyfills. A polyfill (a term coined by Remy Sharp in 2009) is code that implements a missing API using features the browser already has, so your code can use the modern API everywhere. A ponyfill provides the same function without modifying global objects.
Polyfills can only add APIs. New syntax cannot be polyfilled, because an older engine fails while parsing code it doesn't understand, before any polyfill can run. Syntax needs transpilation at build time.
| Need | Example | Solution |
|---|---|---|
| Missing API (function, object, method) | Array.prototype.at, structuredClone, fetch | Polyfill (core-js, or a hand-written one) |
| Unsupported syntax | ?., ??, class fields, arrow functions | Transpiler (Babel, TypeScript, esbuild, SWC) |
| Missing CSS feature | display: grid, container queries | @supports with a fallback layout |
// Source code (ES2020)
const city = user?.address?.city;
// Transpiled for older engines: same behaviour, older syntax
var _user$address;
var city = user === null || user === void 0
? void 0
: (_user$address = user.address) === null || _user$address === void 0
? void 0
: _user$address.city;
Example: feature detection in JavaScript and CSS
// Check that the API exists before using it
if ('IntersectionObserver' in window) {
lazyLoadImagesWithObserver(); // modern path
} else {
loadAllImagesNow(); // simpler fallback that still works
}
// Check CSS support from JavaScript
if (CSS.supports('selector(:has(a))')) {
document.documentElement.classList.add('has-support');
}
// Some features exist but can still fail (Safari private mode, quotas, disabled storage)
function storageAvailable() {
try {
const key = '__test__';
localStorage.setItem(key, key);
localStorage.removeItem(key);
return true;
} catch {
return false;
}
}
/* Baseline layout that works everywhere */
.gallery {
display: flex;
flex-wrap: wrap;
}
/* Enhanced layout only where Grid is supported */
@supports (display: grid) {
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
Example: writing and loading a polyfill
// A simplified polyfill for Array.prototype.at (ES2022)
if (!Array.prototype.at) {
Object.defineProperty(Array.prototype, 'at', {
value: function at(index) {
const len = this.length;
let i = Math.trunc(index) || 0; // NaN or undefined -> 0, 2.7 -> 2
if (i < 0) i += len; // negative index counts from the end
return i < 0 || i >= len ? undefined : this[i];
},
writable: true,
configurable: true,
enumerable: false, // keep it out of for...in loops, like built-ins
});
}
// Load a polyfill only when needed, so modern browsers don't pay for it
if (!('structuredClone' in globalThis)) {
await import('./polyfills/structured-clone.js');
}
Supply-chain caution. In 2024 the popular polyfill.io CDN domain changed owners and started serving malicious code to the sites that embedded it. Prefer self-hosted or bundled polyfills (for example core-js through Babel's useBuiltIns: 'usage') over scripts loaded from third-party domains you don't control.
Key Takeaways
- Detect features, not browsers. UA sniffing is fragile and goes out of date.
- Use
inchecks,typeof,CSS.supports()and@supports, and wrap features that can fail at runtime intry/catch. - Polyfills add missing APIs. Transpilers rewrite unsupported syntax. Neither replaces the other.
- Load polyfills conditionally and self-host them to reduce bundle size and supply-chain risk.
- Progressive enhancement starts from a version that works for everyone and adds features where they are available.
Practice
- (Beginner) Write feature checks for the Clipboard API, the Geolocation API and the
<dialog>element, logging which are supported in your browser.- (Intermediate) Write a polyfill for
Object.hasOwn(obj, prop)that only installs itself when missing, does not show up during enumeration, and behaves correctly for objects created withObject.create(null).- (Interview) "Why is feature detection preferred over user-agent sniffing, and is there any case where UA sniffing is still justified?" Hint: think about features that technically exist but are broken in one specific engine version, and about analytics, compared with deciding which code path to run.
<a id="2-document-structure-and-presentation"></a>