This is the third of four parts, where front-end work stops being about building the interface and starts being about shipping it safely, quickly, and in a state the next person can maintain.
The ground here is build systems, security, storage and privacy, offline behaviour, internationalisation, CSS at scale, performance and design systems. These share a shape: each one is a set of defaults you inherit rather than choose, and the defaults stay invisible until the day they bite.
A bundle that will not shrink. A session cookie an injected script can read. A date that reads as the wrong month in half the world. None of these are exotic — they are the problems that arrive after the feature work is done.
A bundler only makes sense once you know what it is being handed.
- CommonJS is Node's historical format —
require() in, module.exports out. Synchronous and dynamic: require is an ordinary function call evaluated when the line runs, so the path can be computed and the call can sit inside a condition.
- ES Modules are the standard, and static by design.
import/export are declarations, resolved at parse time before any of the module body executes.
1// CommonJS: an ordinary call, so the path can be built at runtime
2if (process.env.NODE_ENV !== "production") {
3 const inspector = require(`./devtools/${toolName}.js`);
4 inspector.attach();
5}
6
7// ESM: a declaration resolved before the body runs, so the path is a literal
8import inspector from "./devtools/inspector.js";
That second property is what everything else in this section rests on. Because the graph of what imports what is knowable without running anything, a build tool can trace it, work out which exports are never reached, and rewrite the result with confidence.
ESM does have a dynamic form, import(), but it returns a promise and is deliberately the exception: it marks a deliberate split in the graph rather than a hole in the analysis.
UMD and AMD came from the years before browsers had modules. You will meet them in older packages; you are unlikely to write one.
- Webpack — the default for most of the last decade and still everywhere. Configurable to the point of being a small programming language, with a plugin for everything, paying for that generality in build times.
- Rollup — from the library side. Clean ESM output, and where tree-shaking was first taken seriously.
- esbuild — written in Go, fast enough that other tools use it as a component rather than compete with it.
- Vite — the current default for applications.
Vite is worth understanding in detail because it behaves differently in development and production. In dev it does not bundle at all: it serves your source as native ES modules and transforms each file as the browser requests it, so startup barely moves as the project grows. Dependencies are pre-bundled once with esbuild, because node_modules is where the file count explodes. For production it bundles with Rollup — serving several hundred unbundled modules to a phone is a very different problem from serving them to localhost.
Hot module replacement swaps the updated module into the running application rather than reloading, so the state you were in — the open modal, the half-filled form — survives the edit.
Tree-shaking is dead-code elimination across the module graph: exports nobody imports never reach the bundle. It works precisely because ES Modules are static.
Three things break it, and it is worth recognising all three in a dependency before you add it:
- CommonJS cannot be shaken reliably, because whether a branch runs is a runtime question. A package shipping only CJS generally arrives whole. Packages offering both advertise the ESM entry through the
exports field.
- Side effects defeat it. If merely importing a module does something — installs a polyfill, registers a global, patches a prototype — removing the import would change behaviour, so the bundler keeps it.
"sideEffects": false in package.json is explicit permission to drop unused imports; the field also accepts an array of paths for files that genuinely do have effects.
- Namespace imports give the bundler far less to work with, and against a CommonJS package nothing at all.
1import * as _ from "lodash-es";
2_.debounce(save, 200); // the whole namespace is reachable
3
4import { debounce } from "lodash-es";
5debounce(save, 200); // only debounce and its dependencies survive
A dynamic import() is the mechanism underneath all of it. Wherever one appears, the bundler emits a separate chunk. Everything else is policy about where to put those calls:
- By route — the highest-value default and easiest to reason about. A user who never visits settings never downloads it.
- By component — for the heavy, occasional things: a charting library, a rich text editor, a map.
- Vendor splitting — third-party code changes far less often than yours, so a deploy invalidates your application code without invalidating the framework alongside it.
1import { lazy, Suspense } from "react";
2
3const Chart = lazy(() => import("./Chart")); // emitted as its own chunk
4
5function Report({ data }) {
6 return (
7 <Suspense fallback={<ChartPlaceholder />}>
8 <Chart data={data} />
9 </Suspense>
10 );
11}
The balance is real in both directions. Too few chunks and the first load is enormous. Too many and you pay per-request overhead and risk waterfalls, where one chunk must arrive before the browser learns it needs the next. Split where usage genuinely diverges, not to make the numbers smaller.
Attacker-controlled text executing as script inside somebody else's page. Because it runs on your origin, it inherits everything that origin can do: read cookies not marked HttpOnly, read and rewrite the DOM, and call your API as the logged-in user.
It arrives by three routes:
- Stored — persisted in a comment, display name or profile field and served to everybody who views it. The most damaging, because the victim need only load the page.
- Reflected — comes straight back out of the request, typically a query parameter echoed into the response. Needs the victim to follow a crafted link.
- DOM-based — never involves the server. Untrusted data flows from
location, a fragment or a postMessage into a dangerous sink entirely on the client.
1const name = new URLSearchParams(location.search).get("name");
2
3el.innerHTML = `Hello, ${name}`; // <img src=x onerror=steal()> executes
4el.textContent = `Hello, ${name}`; // inert, whatever the string contains
A plain <script> inserted through innerHTML does not execute, which is how people talk themselves into believing innerHTML is safe. An <img onerror> executes immediately, which is why it is not.
The primary defence is contextual output encoding — escape according to where the data lands, because the rules for HTML text, an attribute, a URL and a script body all differ. React escapes interpolated values by default, which is why XSS in a React application almost always involves dangerouslySetInnerHTML or a user-supplied javascript: URL reaching an href.
When you must render user-authored markup, sanitise with DOMPurify rather than a regular expression of your own. Avoid the dangerous sinks entirely: no eval, no innerHTML from untrusted data, no document.write.
CSRF tricks an authenticated browser into sending a state-changing request to a site where the user is already logged in. It works because the browser attaches cookies based on where the request is going, not on which page initiated it.
The attacker never reads the response — the same-origin policy still prevents that — so the only thing worth forging is a side effect. That is why the defence lives on writes.
SameSite is the modern first line. Chromium treats a cookie with no SameSite attribute as Lax, which neutralises the classic vectors with no work on your part. Support for that default is not uniform, so treat it as protection to be glad of rather than to rely on alone.
- A CSRF token is the explicit defence: an unpredictable server-generated value tied to the session, embedded in the form and verified on the way back. The attacker's page cannot read it across origins and cannot guess it.
Hold the distinction clearly: XSS is the attacker running script inside your page; CSRF is the attacker causing a request without running any script at all. They are not symmetrical — an XSS hole defeats CSRF protection completely, since script on your origin can simply read the token first.
CSP (Content Security Policy)
A response header declaring where resources may load from and what may execute. script-src 'self' means only same-origin scripts run, so a script injected from an attacker's domain is blocked even though the injection succeeded.
Inline scripts are the difficult part, because a policy cannot tell yours from an injected one by position alone. Two mechanisms can:
- A nonce — a random value generated per response, placed on both the header and the script tag.
- A hash of the exact script contents.
Either is far safer than 'unsafe-inline', which switches the protection off. Usefully, when a nonce or hash is present the browser ignores 'unsafe-inline' entirely, so you can keep it as a fallback for old browsers without weakening modern ones.
1<!-- Content-Security-Policy: script-src 'self' 'nonce-2r8f9x'; object-src 'none' -->
2<script nonce="2r8f9x">startApp();</script>
3<script>stealSession();</script>
The second tag was injected, carries no nonce, and never runs.
Roll a policy out with Content-Security-Policy-Report-Only first — it reports violations without blocking, which lets you find the inline handler nobody remembered before it becomes an outage. CSP is defence in depth, not a replacement for encoding your output.
| Header | Closes off |
|---|
Strict-Transport-Security | The first plaintext request an attacker could intercept |
X-Content-Type-Options: nosniff | Type-guessing that turns an uploaded text file into a script |
X-Frame-Options / CSP frame-ancestors | Clickjacking — who may embed your page in a frame |
Referrer-Policy | How much of the URL leaks when a user navigates away |
Permissions-Policy | Which capabilities the page and its frames may use — so an embedded third party cannot quietly ask for the camera |
Each is a line of configuration for a whole category of attack.
- Validate and authorise on the server. Client-side validation is a courtesy, not a control — anybody can send the request directly.
- Encode at output, by context, not at input. The correct escaping depends on where the value is rendered, and at the moment data arrives you do not yet know. Escaping early also corrupts the data, so a name containing an apostrophe is stored wrong forever.
- Use parameterised queries, so values can never be parsed as commands.
- Keep privilege minimal — narrow cookie scopes, narrow CSP allowances, narrow permissions. Each shrinks what a single mistake can reach.
The security lives entirely in the attributes.
HttpOnly makes the cookie invisible to JavaScript — the single most important defence against an XSS bug becoming a stolen session. The injected script can send requests as the user, but cannot read the token and post it elsewhere. Auth tokens belong here.
Secure restricts it to HTTPS.
SameSite controls cross-site travel (below).
Domain / Path should be as narrow as the application allows. A cookie scoped to a parent domain is available to every subdomain, including the one somebody set up for a marketing campaign and forgot.
Four mechanisms, less interchangeable than they look:
localStorage — synchronous, persists until cleared, strings only. The synchronicity is a real performance consideration: every read and write blocks the main thread. More importantly, anything in it is readable by any script on the origin, so an XSS bug drains it instantly. Wrong for auth tokens, fine for a theme preference.
sessionStorage — same API and constraints, scoped to one tab, cleared when it closes.
- IndexedDB — the browser's real database: asynchronous, transactional, large, structured objects with queryable indexes. Right for offline data and cached API responses.
- The Cache API — stores
Request/Response pairs. The storage layer behind service workers, not a general-purpose store.
Strict — never sent on any cross-site request, including a plain link. Safest, and produces the surprising experience of following a link from another site and arriving logged out.
Lax — the sensible middle, and what browsers increasingly apply when the attribute is missing. Sent on top-level navigations using safe methods, withheld from cross-site subrequests such as a form POST, an image request or a cross-origin fetch. That is precisely the set CSRF depends on.
None — sent on every cross-site request, and must be paired with Secure. For cases that genuinely need cross-site identity, such as an embedded widget authenticating against its own origin.
A script running in the background on its own thread, positioned between the page and the network as a programmable proxy. No DOM access, entirely asynchronous, and it keeps running after the page that registered it has closed.
Its lifecycle explains most of the confusion:
- The page registers the worker.
install fires once — where an app shell is normally pre-cached.
activate fires — where old caches are cleaned up.
The subtlety is the second deploy: a new worker installs and then waits, because pages controlled by the old one are still open and swapping the proxy underneath them would be unsafe. It takes over once those pages are gone — unless it calls skipWaiting to activate immediately and clients.claim() to take control of open pages.
1self.addEventListener("fetch", (event) => {
2 event.respondWith(staleWhileRevalidate(event));
3});
4
5async function staleWhileRevalidate(event) {
6 const cache = await caches.open("assets-v1");
7 const cached = await cache.match(event.request);
8
9 const fresh = fetch(event.request).then((response) => {
10 cache.put(event.request, response.clone()); // clone before the body is read
11 return response;
12 });
13
14 event.waitUntil(fresh.catch(() => {})); // keep the worker alive for the update
15 return cached || fresh;
16}
Three strategies cover most needs:
- Cache-first — serve from cache, network only on a miss. Suits static versioned assets that cannot change under a given URL.
- Network-first — try the network, fall back to cache. Suits content where staleness is worse than waiting.
- Stale-while-revalidate (above) — return cached immediately, refresh in the background. The best balance for a great many resources.
Service workers require HTTPS, with localhost exempted so development still works.
An ordinary web application that has met the browser's bar for being installed to the home screen and launched like a native one.
The hard requirements are a web app manifest and HTTPS. The manifest declares name, icons at the sizes the platform needs, a start_url, a display mode (standalone hides the browser chrome) and theme colours. Browsers also expect usability without a network, which in practice means a service worker — though the exact installability bar differs between browsers and has shifted more than once.
You gain installability, offline capability, and where supported push notifications and background sync, with no app store in between. The trade is depth: a web application still cannot reach as far into the device, and platform APIs vary considerably between browsers.
The client-side database the offline patterns depend on: asynchronous, transactional, generous with storage, storing structured objects with indexes. If you are keeping data on the device — a queue of pending writes, a cached collection, a document edited offline — this is where it goes.
The honest assessment is that the native API is verbose and awkward, built around request objects and event handlers from an earlier era. Nearly everybody uses a wrapper — idb is a thin promise-based layer, Dexie a richer one. Hold the model — asynchronous, transactional, object-based, indexed — and know you will reach for a wrapper.
- Internationalisation is the engineering work that makes an application capable of being localised: strings pulled into message files, sentences never assembled from fragments, dates and numbers formatted through locale-aware APIs. Done once, for the application.
- Localisation is adapting it to one specific locale: translated text, imagery, currency, cultural conventions. Done once per locale, repeatedly.
The cardinal mistake is hardcoding assumptions about grammar. "You have " + count + " items" looks harmless in English and breaks in most other languages, where word order differs, the noun inflects with the number, and there are not two plural forms. The fix is message formatting with placeholders — usually ICU MessageFormat — which puts the whole sentence including its plural logic inside the translation file where a translator can work on it.
Languages disagree about how many forms exist. English has two. Arabic has six. Polish and Russian have forms depending on the last digit. count === 1 ? "item" : "items" is a rule about English embedded in your source code.
Intl.PluralRules maps a number to its CLDR category — zero, one, two, few, many, other — for a given locale, and ICU MessageFormat uses those categories to pick the right string.
1const rules = new Intl.PluralRules("ar-EG");
2rules.select(0); // "zero"
3rules.select(1); // "one"
4rules.select(3); // "few"
5rules.select(11); // "many"
6
7// Each translator supplies only the categories their language uses:
8// en: "{count, plural, one {# item} other {# items}}"
9// ar: "{count, plural, zero {…} one {…} two {…} few {…} many {…} other {…}}"
03/04 is March the fourth in the United States and the third of April nearly everywhere else. Month names differ, clocks are twelve- or twenty-four-hour, and time zones and calendar systems sit underneath. Never format a date by hand.
The storage rule: keep dates as UTC in ISO 8601, and format only at display, for the user's locale and zone.
1new Intl.DateTimeFormat("en-GB", {
2 dateStyle: "long",
3 timeStyle: "short",
4 timeZone: "Europe/London",
5}).format(new Date("2026-03-14T09:30:00Z")); // "14 March 2026 at 09:30"
6
7new Intl.NumberFormat("de-DE", {
8 style: "currency",
9 currency: "EUR",
10}).format(1234.5); // "1.234,50 €"
Two further pieces complete the picture:
- Right-to-left languages need the layout itself to mirror. CSS logical properties are the answer — write
margin-inline-start rather than margin-left and the layout flips correctly under dir="rtl", with no separate stylesheet.
- Locale negotiation takes the languages the user asked for and chooses the best available translation, falling back sensibly when there is no exact match.
The Intl family — plural rules, dates, numbers, relative times, list formatting, segmentation — is the native answer to nearly all of this, and it already ships in the browser.
Maintainable CSS
CSS is globally scoped and resolved through a specificity-based cascade — exactly what you want in a single stylesheet and exactly what hurts at scale. Styles reach across components, overrides accumulate, specificity creeps up, and eventually nobody can delete a rule with any confidence.
Every methodology below answers the same question: how do we keep this deletable.
- Component styles name the thing —
.card, .nav-link — and keep its styling in one place. Markup stays readable and intent is obvious, but the stylesheet grows with the application and unused rules are invisible.
- Utility styles do the opposite. Single-purpose classes composed in the markup. The CSS stays small because classes are reused, and deleting a component genuinely deletes its styling — but the markup carries long class strings.
Most teams blend them: utilities for layout and spacing, where the vocabulary is small and repetitive; components for complex repeated patterns where a name is worth more than a list.
A naming convention rather than a tool: .card, .card__title, .card--featured. Everything is a single class, so specificity stays flat, and the name declares what a rule belongs to.
Its strength is needing nothing installed. Its weakness is that the naming is long and entirely manual, so it holds up exactly as well as the team's discipline does.
styled-components and Emotion move styles into JavaScript, colocated with the component. Scoping is automatic, styles can be computed from props, and removing a component removes its styles because they were never separate.
The costs are equally real: generating styles at runtime means main-thread work on every render, the library adds to the bundle, and the interaction with streaming SSR is awkward because styles must be collected and inserted while HTML is already in flight.
The field responded by moving the work earlier. Zero-runtime libraries — vanilla-extract, Linaria, Panda CSS — keep the authoring experience but extract to static CSS at build time. Runtime versus build time is essentially the whole debate.
One class per declaration: .mt-2 is nothing more than margin-top: 0.5rem. Tailwind is the popular embodiment.
The property that makes it work is that the CSS stops growing. Classes are shared across the application and unused ones removed at build time, so the stylesheet approaches a fixed size regardless of how many components exist. The criticism is visible immediately: crowded markup, and a vocabulary to learn before you can read it fluently.
It is worth knowing how much the platform has absorbed:
- CSS Modules give build-time scoping with ordinary CSS.
- Cascade layers (
@layer) order groups of rules explicitly, so a reset can lose to a component without either fighting on specificity.
- Native nesting removed one of the last reasons to reach for a preprocessor.
- Container queries let a component respond to the space it was given rather than the viewport.
Several problems these methodologies existed to solve are now solvable directly.
The sequence before anything meaningful appears: parse HTML into the DOM, CSS into the CSSOM, combine into the render tree, lay out, paint.
Two things block it, differently:
- CSS is render-blocking. The browser will not paint until it has the CSSOM, because painting with incomplete styles means showing something and then changing it. The response is to inline the CSS for the first screen and load the rest asynchronously.
- JavaScript is parser-blocking. A plain
<script> stops HTML parsing while it downloads and executes. defer downloads in parallel and executes after parsing, in document order; async executes the moment it arrives, in whatever order downloads complete. Use defer for your own scripts, async for genuinely independent third-party ones. Module scripts are deferred by default.
For the full six-stage breakdown and how each stage maps onto the vitals below, see Understanding the Critical Rendering Path.
| Metric | Measures | Target | Main lever |
|---|
| LCP | When the largest content element finishes rendering | < 2.5s | Preload the hero image, fetchpriority="high", keep it out of lazy-loading |
| INP | Input to the next frame reflecting it, across the whole visit | < 200ms | Break up long main-thread tasks |
| CLS | Visual instability after paint | < 0.1 | Reserve space for images, ads, embeds and late fonts |
INP replaced First Input Delay in 2024, because measuring only the first interaction flattered pages that got slow later.
Shipping less JavaScript is the largest general lever, particularly for INP. Beyond that: code splitting, image optimisation, preconnecting to origins you know you will use, and virtualising long lists.
Measure both ways — Lighthouse for controlled lab data, real user monitoring for what your actual users on their actual devices experience. The two diverge more than most people expect.
Rendering work comes in three tiers of cost:
- Layout computes geometry; a single change can cascade through the whole document. Most expensive.
- Paint rasterises into pixels within layers. Still main-thread work.
- Compositing is the GPU assembling layers into the final frame — by far the cheapest, because it runs on the compositor thread independently of whatever the main thread is doing.
This is why the animated property matters so much. transform and opacity affect only compositing, so an animation using them runs on the GPU while the main thread is busy. Animating width, top or margin recomputes geometry every frame.
will-change can promote an element to its own layer, but layers cost memory — apply it to the specific element you are animating rather than broadly and hopefully.
Design tokens are the foundation, and the layering is what makes them useful:
- Primitive tokens hold raw values with descriptive names —
blue-500, space-4.
- Semantic tokens point at primitives with names describing intent —
color-primary, color-danger.
- Components only ever reference the semantic layer.
1:root {
2 --blue-500: #3b82f6; /* primitive: what the value is */
3 --color-primary: var(--blue-500); /* semantic: what it is for */
4}
5
6[data-theme="dark"] {
7 --color-primary: var(--blue-300);
8}
9
10.button--primary {
11 background: var(--color-primary);
12}
That one level of indirection makes a dark theme or a white-label build a change to a handful of variable declarations rather than a search through every component.
On top sits a component library implementing the primitives — button, input, modal, menu — with states, variants and accessibility built in. Getting focus management and keyboard behaviour right once, at this layer, is what makes them right everywhere by default, which is far more reliable than asking every team to remember.
Documentation is the third piece, usually Storybook. A component nobody can find gets rebuilt.
Maintaining Design Systems
The difficult part is not building it. It is keeping it alive and adopted once several teams depend on it — and most of that difficulty is organisational rather than technical.
- Versioning. The system is now a dependency for people who did not write it: semantic versioning, readable changelogs, codemods where feasible, deprecation periods instead of removals. A breaking change shipped without warning teaches teams to pin the version and stop upgrading, which is the beginning of the end.
- Governance decides who owns it, how a team proposes an addition, and how proposals are reviewed for consistency rather than merged because somebody needed them on Thursday. Without it, a design system becomes a component dumping ground.
- Adoption has to be measured rather than assumed, and encouraged by making the system genuinely easier to use than writing your own. Teams route around friction, and every local reimplementation is a small ongoing fee.
- Design-to-code sync drifts fastest. Token pipelines such as Style Dictionary generate platform-specific files from one source, so the values in Figma and the values in CSS cannot quietly disagree.
Building the components is the easy portion. The durable value is in the governance, versioning and adoption around them.
That is Tier 3. Very little of it is about writing features, and all of it is about the conditions features are written under — how code is built and delivered, what an attacker can reach, what survives a lost connection, and what happens when a hundred components share one cascade.
Tier 4 goes underneath again, into architecture and platform machinery: local-first data and CRDTs, microfrontends, server-driven UI, WebGL and WebGPU, WebAssembly, and the browser internals that explain why everything you have read so far behaves the way it does.