Architecture decisions
- Lesson 16
- 65 min
- Module 5: Performance and scalability
Lesson 16 of 19 · Frontend Architecture and Performance for AI AppsArchitecture decisions
1. Web foundations bridge
2. Components and application structure
3. Data fetching and state management
4. AI-specific interaction states
5. Performance and scalability
6. Security, accessibility and handoff
What you will be able to do
Kwame asks whether the desk should be drawn on a server instead of in the browser. Compare the three ways to make a page (client rendering, server rendering and static pages) by what the product needs. Write the decision down with its reasons, and say when you would change it.
Before you startWhat to know and to have ready
What you need to know
- The three kinds of wait and the first-rows mark, from Measure the browser.
- The virtual list and code splitting, from Load less work.
- HTTP requests, answers and headers, from Build and Deploy Your First API.
What you need on your computer
- Your project after the last lesson:
npm testprintspass 21andTests 43 passed (43), andnpm run buildmakes four JavaScript files. - The mock API running in a first terminal (
npm run api).
This lesson ends with the practice for the whole module, about 25 minutes of it. Tested with Node.js 24.18.0 on macOS (Apple silicon), in Chromium 156.
Three ways to get a meal
Section titled “Three ways to get a meal”There are three ways to get a dinner. A meal kit arrives at your door as a box of ingredients and a recipe card. The box comes fast, but you do the cooking, and the meal is ready only when you are done. In return, you can cook it your way, again and again, without asking anyone.
In a restaurant, the kitchen cooks your order and brings it ready to eat. You eat sooner, but the kitchen works for every single order. When the restaurant is full, everyone waits for the kitchen.
A sandwich made in the morning is ready before anyone asks. It is handed over at once and costs almost nothing per customer. But it is the same sandwich for everybody, and it is only as fresh as the morning.
Web pages are made in the same three ways. The question is never “which is best?”. It is “what does this product need?”
The problem: an almost empty page
Section titled “The problem: an almost empty page”Kwame, the solution architect, opens the desk’s production files and finds dist/index.html. This is all of it, 394 bytes:
<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Ticket desk</title> <script type="module" crossorigin src="/assets/index-DnbxJFGT.js"></script> <link rel="stylesheet" crossorigin href="/assets/index-Cq7Ckq9E.css"> </head> <body> <div id="root"></div> </body></html>“The body is one empty div,” he says. “Without JavaScript, the page shows nothing. A search engine sees nothing. On Omar’s phone, nothing useful appears until 93 kB of JavaScript have arrived and run. Big sites draw their pages on the server. Shouldn’t we?”
His facts are right. On the phone-like profile of the last lesson, FCP came at 872 ms and first rows at 1,102.1 ms. Almost all the time before FCP was the download of the JavaScript. But “big sites do it” is not a reason. A choice like this is an architecture decision. It is hard to change later, so you make it on purpose, from the product’s needs, and you write it down.
The idea: three rendering choices
Section titled “The idea: three rendering choices”Rendering means turning data and components into the HTML that the screen shows. The three choices differ in where and when that happens.
- Client rendering. The server sends a small HTML file and the JavaScript. The browser, which is the client, runs the components, asks the API for data and builds the page. This is the meal kit, and it is how the desk works now.
- Server rendering. For every request, a server runs the same components with the data and sends HTML that already holds the content. Then the JavaScript arrives and attaches its event handlers to that HTML, so buttons and forms start to work. This step has a name: hydration. The HTML is like dried food, and the JavaScript is the water that brings it to life. This is the restaurant.
- Static pages. The HTML of every page is made once, before anyone asks, by a build. Any simple web server or content delivery network (CDN) hands out the files. This is the sandwich made in the morning. The page that you are reading is one: Nextia Learning builds its lessons as static pages.
Text description of the diagram
The diagram has three rows. In client rendering, the server sends a small HTML file and the JavaScript, the browser runs the JavaScript, asks the API and draws. The content appears only after the browser has done all of it. In server rendering, the server first gets the data and writes the HTML. The HTML arrives with the content in it. Then the JavaScript arrives, and the page starts to work. In static pages, a build wrote the HTML once, before anyone asked. It arrives with the content, and nothing is done per visit. The lengths of the steps are not to scale.
Real products also mix them. A static frame of the page can fetch its data in the browser. A server can render the first screen and let the browser do every later screen. The three plain choices are enough to reason about the mixes.
A tiny example: one ticket list, three ways
Section titled “A tiny example: one ticket list, three ways”Take the first two rows of the ticket list. Here is what the server sends in each choice.
Client rendering sends the 394 bytes above. The rows exist only after the browser has run the JavaScript and the API has answered.
Server rendering sends the rows inside the HTML. A server program asks the ticket API for this agent’s first page and writes, for example:
<ul class="rows"> <li><a class="row" href="#/tickets/T-80010">… Return label for my order LK-300210 …</a></li> <li><a class="row" href="#/tickets/T-80009">… My order LK-55190 never arrived …</a></li></ul>The agent sees the rows when the HTML arrives, before any JavaScript runs. But the server must run React for every request, and the links work only after hydration.
Static pages would write the same HTML once, in a build. But which tickets? The ones of the moment of the build. A ticket that arrives one minute later is not in the page. And every agent would see the same page, including tickets that are not theirs to see. For the desk’s live, private data, a static page does not work at all.
| Client rendering | Server rendering | Static pages | |
|---|---|---|---|
| Who makes the HTML | The browser, for every visit | A server, for every request | A build, once |
| Content visible | After the JavaScript and the API | When the HTML arrives | When the HTML arrives |
| Without JavaScript | Nothing | Content, but buttons do not work | Content |
| Work on a server per visit | None (only the API) | Run the components | None |
| Fresh data | Yes, from the API | Yes, at request time | Only as fresh as the last build |
| What you must run | Static files and the API | A server that runs JavaScript, and the API | Static files |
Larkfield’s decision
Section titled “Larkfield’s decision”Sam and Kwame go through the desk’s needs, one by one:
- Who uses it? Larkfield’s agents, signed in, on their work computers. They open the desk in the morning and keep it open all day. The first load happens once a day; every later screen change is fast, because the code is already in the browser.
- Must search engines see it? Never. Tickets hold customers’ personal data. A page that a search engine could read would be a leak.
- How fresh is the data? It changes every minute, and each agent sees different tickets. A static page cannot hold it.
- What is cheapest to run? Client rendering: the files are static, so any web server or CDN can serve them. Only the API runs code.
So the desk stays rendered in the browser. Omar’s phone complaint is real, but the last two lessons answered it more cheaply: a smaller first file and a virtual list. Server rendering would add a second server that runs JavaScript for every request and must be kept secure. All that would save part of one load per day.
Kwame then asks about two other pages that Larkfield plans. The flowchart is the reasoning for all three:
Text description of the diagram
The flowchart asks two questions. First: must people without the app, or search engines, see the content? If not, because the users are signed in and the data is private, choose client rendering, as for the ticket desk. If yes, ask the second question: is the content the same for everyone, and does it change slowly? If yes, choose static pages, as for a help centre. If it is different per person, or changes every minute, choose server rendering, as for an order status page.
- A public help centre with articles for customers (“How do I return an item?”). Everyone sees the same articles, they change a few times a week, and customers must find them with a search engine. Static pages: built when an article changes, served from a CDN, fast on any phone.
- An order status page, opened from a link in an email, mostly on phones. Each customer sees their own order, and it changes during the day. The content should appear at once, without a large download. Server rendering is a fair choice here, with care: the page holds personal data, so no shared cache may keep it. The failure case below shows why.
When to choose each, and what it costs
Section titled “When to choose each, and what it costs”- Client rendering fits tools that people sign in to and keep open: dashboards, editors, the ticket desk. It is the cheapest to run. It costs a slower first load, especially on phones, and an empty page without JavaScript.
- Server rendering fits pages that must show personal or fast-changing content at once, often on slow devices. It costs a server that runs JavaScript for every request, with its own scaling, monitoring and security. Hydration also needs the same JavaScript as client rendering, so the download does not disappear; it only stops blocking the first view.
- Static pages fit content that is the same for everyone and changes slowly: documentation, help centres, marketing pages, this course. They are the fastest and cheapest. They cost a new build for every change, and they cannot show private or live data without client code.
Whatever you choose, write it down. An architecture decision record is a short document. It names the choice, the reasons, the costs that you accept, and the conditions that would change it. Six months later, nobody has to guess why the desk works this way.
Guided practice: write the decision record
Section titled “Guided practice: write the decision record”-
In
~/projects/ticket-desk, make a folderdocs. -
In it, make a file
rendering-choices.mdwith three parts. First, a table of the three choices: what the server sends, what each is good for, what each costs. Then Our decision, with the reasons. Last, When we would change it. -
For each reason, write the fact that supports it. Use your own measurements where you have them: the size of the first file from
npm run size, and first rows on the phone-like profile. -
Compare your file with the course’s version:
../nextia-learning-labs/C22/snapshots/end-of-m05/docs/rendering-choices.md. -
Look at what the desk shows without JavaScript. Run
npm run buildandnpm run preview, and open http://localhost:4173/. In DevTools, open the command menu (Ctrl+Shift+P, or Cmd+Shift+P on a Mac), typeDisable JavaScript, press Enter, and reload. -
Turn JavaScript on again: in the command menu, run
Enable JavaScript. -
Copy the last files of this module from the snapshot. The README now lists
npm run size,npm run build:profileand thedocs/folder, andpackage.jsonand the lock file get the version 0.5.0:Terminal window S=../nextia-learning-labs/C22/snapshots/end-of-m05cp "$S/README.md" "$S/package.json" "$S/package-lock.json" .Terminal window $S = "..\nextia-learning-labs\C22\snapshots\end-of-m05"Copy-Item "$S\README.md", "$S\package.json", "$S\package-lock.json" .Your project now has the same files as the snapshot
end-of-m05, except your own words indocs/rendering-choices.md.
Show the expected result
Step 4: the course’s decision record has the same three parts. Its decision reads:
The ticket desk is rendered in the browser (Vite builds static files; the browser draws the screens).
- Its users are signed-in agents who keep it open all day. The first load happens once a day; after that, every screen change is fast, because the code is already in the browser.- Search engines must never see tickets. Server rendering for search is not needed.- The data changes every minute and is different for each agent: a static page cannot hold it.- The files are static, so any web server or CDN can serve them. Only the API runs code.and ends with When we would change it. A public page that must appear fast on slow phones and be found by search engines gets server rendering or static pages, as a separate site. The desk stays as it is. Your words can differ. Check that each reason is a fact about the product, not a fashion.
Step 5: a white page. The tab’s title is still “Ticket desk”, because the title is in the HTML. Everything else comes from JavaScript.
Your turn: a status page for customers
Section titled “Your turn: a status page for customers”Predict first, then decide. Larkfield wants a public page “Is the help desk busy?”. It shows the expected wait for a reply, updated every 5 minutes, the same for every visitor. Many customers open it on phones, and it must be found by a search for “Larkfield support”. Which choice, and what does it cost? Write the decision in four lines, as in the decision record.
Show the answer
Static pages, rebuilt every 5 minutes, or a static page with one small client request for the number. The content is the same for everyone, so there is no reason to render it per request. It must be found by search engines and appear fast on phones, so client rendering of the whole page is a poor fit. A build every 5 minutes is cheap, and a CDN serves the page fast anywhere. The cost: the number can be up to 5 minutes old, and someone must watch the job that rebuilds the page. If the number had to be live to the second, keep the static page and fetch only the number in the browser.
Common mistake
A server-rendered private page in a shared cache
This is a constructed example: the course did not build it, but the mistake is common.
Symptom: Larkfield’s order status page is rendered on the server. A CDN in front of it keeps each page for 60 seconds, to save server work. A customer opens the link in her email and sees another customer’s name and address.
Cause: the page’s address is the same for every customer, /my-order. The server finds the customer from a cookie. The CDN keeps pages by address only, so it gave the first customer’s HTML to everyone who asked in the next 60 seconds. With client rendering, the shared HTML would be the empty frame, and each browser would fetch its own data with its own sign-in. Server rendering puts the private data into the HTML, so the HTML itself becomes private.
Fix: send the header Cache-Control: private, no-store with every page that holds personal data. Cache-Control is the HTTP header that tells caches whether, and how long, they may keep an answer; private, no-store means that no shared cache may keep it. Let the CDN cache only the static files and pages that are the same for everyone. Test it: sign in as two different customers and compare the pages.
Consequence in AI work: pages with AI answers are often personal. A draft reply, a summary of a customer’s tickets, an answer from the customer’s documents: each one is private data. If the server renders such a page and a cache keeps it as if it were public, one person’s AI answer reaches another person.
Module practice: measure and improve a seeded problem
Section titled “Module practice: measure and improve a seeded problem”This is the practice for the whole module, with its completion check. First a worked example with every number, then a task for you.
Worked example: the list that loads everything
Section titled “Worked example: the list that loads everything”Tomás has an idea: “Agents hate to wait when they scroll. Let’s load every page at once, and draw every row, so scrolling never waits.” This version of TicketList.tsx does exactly that. It is the seeded problem.
-
Save your own
src/features/tickets/TicketList.tsxasTicketList.good.tsxin the same folder, so you can put it back. -
Replace the content of
TicketList.tsxwith Tomás’s version:import { useEffect, useState } from 'react';import type { TicketFilter } from '../../api/types';import { ErrorMessage } from '../../ui/ErrorMessage';import { useTicketList } from './queries';import { TicketFilters } from './TicketFilters';import { TicketRow } from './TicketRow';export const NO_FILTER: TicketFilter = { status: '', team: '', q: '' };/** SEEDED PROBLEM: loads every page at once and draws every row ("so that scrolling never waits"). */export function TicketList({ selectedId }: { selectedId: string | null }) {const [filter, setFilter] = useState<TicketFilter>(NO_FILTER);const list = useTicketList(filter);const tickets = list.data?.pages.flatMap((p) => p.items) ?? [];// Ask for the next page as soon as one arrives, until the server has no more.const { hasNextPage, isFetchingNextPage, fetchNextPage } = list;useEffect(() => {if (hasNextPage && !isFetchingNextPage) void fetchNextPage();}, [hasNextPage, isFetchingNextPage, fetchNextPage]);// A mark for the browser's performance tools: the first time that ticket rows are on the screen.const firstRows = tickets.length > 0;useEffect(() => {if (firstRows && performance.getEntriesByName('tickets:first-rows').length === 0) performance.mark('tickets:first-rows');}, [firstRows]);return (<section className="list" aria-labelledby="list-heading"><h2 id="list-heading">Tickets</h2><TicketFilters value={filter} onChange={setFilter} />{list.isPending && <p className="muted">Loading tickets…</p>}{list.isError && <ErrorMessage error={list.error} onRetry={() => list.refetch()} />}{list.isSuccess && (<><p className="muted" aria-live="polite">{tickets.length === 0? 'No ticket matches.': `Showing ${tickets.length} tickets${list.hasNextPage ? ' (more on the server)' : ''}.`}{list.isPlaceholderData && ' Updating…'}</p><div className="scroller" tabIndex={0} role="region" aria-label="Ticket list"><ul className="rows">{tickets.map((t) => (<li key={t.ticket_id}><TicketRow ticket={t} selected={t.ticket_id === selectedId} /></li>))}</ul></div></>)}</section>);} -
Run
npm test. Read which test fails, and why. -
Run
npm run buildandnpm run preview, and open http://localhost:4173/ with the Network panel open (no throttling). Watch the line “Showing … tickets” and the number of requests at the bottom of the Network panel until both stop changing. -
In the Console, count the elements in the list:
document.querySelectorAll('.list *').length. -
Put your version back: copy
TicketList.good.tsxoverTicketList.tsx, and deleteTicketList.good.tsx. Run steps 3 to 5 again.
Show the worked example’s results
Step 3: one test fails, the test of the virtual list from the last lesson:
FAIL src/features/tickets/TicketList.test.tsx > draws only the rows in view, not every loaded ticketAssertionError: expected 50 to be less than 25 Tests 1 failed | 42 passed (43)The test caught the problem before any browser did: 50 loaded tickets gave 50 rows in the page.
Steps 4 and 5 and the same steps after the fix, on the course team’s laptop (median of 3 runs, no throttling):
Text description of the chart
In the first load, the seeded list sent 200 requests for tickets; the desk’s own list sent 1.
| Measured the same way | Seeded list | The desk’s list (after the fix) |
|---|---|---|
| Requests for tickets | 200 | 1 |
| Time until the list stops growing | 14,561 ms | 109 ms |
| Elements in the list | 60,022 | 131 |
| Ticket rows in the page | 10,000 | 18 |
| First rows | 96.6 ms | 97.8 ms |
| Longest key press while typing “parcel” | 40 ms | 24 ms or less |
The last row comes from the browser’s Event Timing API: the time from a key press to the next paint. It reports only presses of 16 ms or more, in steps of 8 ms.
First rows came at about the same time in both versions, so Tomás’s change does not show in that number. It shows in everything after it: 200 requests during 14.6 seconds and a page with 60,022 elements. Key presses also take longer, because each one makes React compare 10,000 rows. On a phone, the same work runs about four times slower, and the 10,000 tickets use the phone’s data. That is why a single number is never enough: measure what the agent does next too.
The fix is the list of the last lesson, with no memo and no new code. It asks for one page first, and the next page only near the end. It draws only the rows in view. The improvement is in the table: the same measurements, before and after, in the same way.
Your task: the oversized bundle
Section titled “Your task: the oversized bundle”A teammate wanted the React Profiler to work in production, and changed one line of vite.config.ts:
resolve: { alias: { 'react-dom/client': 'react-dom/profiling' } },Now every build, not only npm run build:profile, uses React’s profiling build. Your task:
- Make this change in your project, so you start from the seeded problem.
- Measure it:
npm run build,npm run size, and first rows on the phone-like profile (Slow 4G, CPU 4×), three times. - Fix it, without any new code other than the fix.
- Measure again, in the same way.
- Write a performance note of five lines or fewer: what you measured, how, the numbers before and after, and the fix.
Completion check. Show an observed improvement: the same measurements before and after the fix, under the same conditions. Show that you added no memo, no new library and no other code. Then explain in two sentences which of the three kinds of wait the fix changed.
Show a worked solution
Measure the seeded problem. On the course team’s laptop:
dist/assets/index-BZyjPyJL.js 319.77 kB │ gzip: 98.41 kBassets/index-BZyjPyJL.js 312.3 KB 94.9 KBJavaScript: 4 files, 100.8 KB gzip in allFirst rows on the phone-like profile, 5 runs: median 1,124.8 ms (1,123.5 to 1,130.8).
Find the cause. The first file grew by 5.4 kB, and no new feature came with it. npm run build:profile gave the same size before the change. So every build now uses React’s profiling build. The fix is the line from Measure the browser:
resolve: mode === 'profile' ? { alias: { 'react-dom/client': 'react-dom/profiling' } } : {},Measure again, in the same way:
dist/assets/index-DnbxJFGT.js 302.28 kB │ gzip: 93.02 kBassets/index-DnbxJFGT.js 295.2 KB 89.7 KBJavaScript: 4 files, 95.6 KB gzip in allFirst rows on the phone-like profile, 5 runs: median 1,102.1 ms (1,094.4 to 1,123.5).
A performance note:
Problem: every production build used React's profiling build (vite.config.ts).Measured: npm run build and npm run size; first rows, Slow 4G + CPU 4x, median of 5, preview build.Before: index.js 98.41 kB gzip; first rows 1,124.8 ms (1,123.5-1,130.8).After: index.js 93.02 kB gzip; first rows 1,102.1 ms (1,094.4-1,123.5).Fix: the profiling alias only in --mode profile. No other change, no memo.The fix changed the download: 5.4 kB less on the network. The time to first rows fell by about 23 ms, close to the spread between runs. So the size is the clear evidence, and the time only agrees with it. That is a normal result: say it as it is, and do not claim more.
Knowledge check
Section titled “Knowledge check”Check your understanding
Kwame says: 'Server rendering is always faster, so let's server-render the desk.' What is true in his claim, and why does Larkfield still keep client rendering for the desk?
Show the answer
True: with server rendering, the content arrives in the HTML, so the first view does not wait for the JavaScript and the API. The desk keeps client rendering because of its product’s needs. Its agents are signed in and keep it open all day, so the first load happens once a day. Search engines must never see tickets. The data is private and changes every minute. Client rendering needs only static files and the API, while server rendering needs a server that runs JavaScript for every request. The first load on phones was improved more cheaply, with a smaller first file and a virtual list.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: Larkfield builds its help centre as static pages, and an article changes. When do customers see the change, and what must happen first?
Show the answer
Only after a new build has made the HTML again and the new files are on the server or CDN. Until then, customers see the old article. A static site needs a build for every change of content. The team runs it, or a tool starts it when an article is saved.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Why is a server-rendered page with personal data riskier in a shared cache than a client-rendered page with the same data?
Show the answer
Server rendering puts the personal data into the HTML, so the HTML is private. A shared cache that keeps pages by address can give one person’s HTML to the next person. A client-rendered page’s HTML is the same empty frame for everyone, and each browser fetches its own data with its own sign-in. Pages with personal data need Cache-Control: private, no-store.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- Client rendering sends a small HTML file and JavaScript, and the browser builds the page. Server rendering sends HTML with the content, then hydrates it. Static pages are HTML made once, by a build.
- Choose from the product’s needs: who uses it, whether search engines must see it, how fresh and private the data is, and the cost to run it. The desk stays client-rendered; a help centre fits static pages; an order status page can be server-rendered, never in a shared cache.
- Write the choice in a decision record, with its reasons, its costs and when you would change it.
- Module 5 in one look: measure first, with the first-rows mark, the waterfall, the size report and the profiling build. Find the largest wait. Load and draw less: one page of data, the rows in view, the code of the open screen. Measure again in the same way.
memowas never the answer here.
Next, Module 6 makes the desk safe: the browser’s security boundaries, generated Markdown, and where secrets must never go.