DEV Community

Cover image for From Scratch to a Million Users — Part 2: What Breaks After Launch
surajrkhonde
surajrkhonde

Posted on

From Scratch to a Million Users — Part 2: What Breaks After Launch

Part 1 was about building the house. Part 2 is about what happens when a thousand people move in, five other builders show up with their own tools, and the inspector starts knocking. Same uncle, same nephew, same chai — longer conversation.


Part 0: The Day After Launch

👦 Nephew: Uncle! We launched! Feature folders, RTK Query, skeletons, lazy loading — everything from Part 1. It's beautiful.

👨‍🦳 Uncle: Congratulations. Now tell me — what happens in three months, when you have real users, four new developers on your team, a 50,000-row table someone insists on rendering, and a security researcher emails you about something called XSS?

👦 Nephew: ...I was hoping we were done.

👨‍🦳 Uncle: Nobody is ever done. Part 1 taught you to build a house that doesn't collapse under its own weight. Part 2 teaches you to build a house that survives a hundred people living in it, five contractors renovating different rooms at once, and an inspector checking if the wiring is safe. Sit down. This one's longer.


Part 1: The "It Works on My Machine" Trap — Testing Strategy

👦 Nephew: We don't really have tests. I click around manually before every deploy.

👨‍🦳 Uncle: And how's that going now that you have four developers touching the same codebase?

👦 Nephew: ...someone broke the checkout flow last week and nobody noticed until a customer complained.

👨‍🦳 Uncle: That's the exact disease testing cures. But before I tell you to "write tests," I want to stop you from making the second-most-common mistake — writing the wrong tests, which feels productive but protects you from nothing.

The Testing Pyramid — and why most teams build it upside down

              ▲
             ╱ ╲            E2E Tests (few)
            ╱   ╲           Slow, expensive, but tests the REAL user journey
           ╱─────╲          "Can a user actually log in, add to cart, checkout?"
          ╱       ╲
         ╱         ╲        Integration Tests (more)
        ╱           ╲       "Does OrdersPage correctly show data from the API
       ╱─────────────╲       and update when a mutation succeeds?"
      ╱               ╲
     ╱                 ╲    Unit Tests (most)
    ╱                   ╲   "Does this one pure function calculate the total
   ╱─────────────────────╲   correctly given these inputs?"
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So we need lots of E2E tests since that's "the real thing"?

👨‍🦳 Uncle: That's the trap! E2E tests spin up a real browser, hit real (or realistic) backends, and take seconds each. A suite of 500 E2E tests takes an hour to run and breaks constantly for reasons that have nothing to do with real bugs — a button moved 10 pixels, a network blip. Teams that over-invest in E2E end up afraid of their own test suite because it's slow and flaky, so they start ignoring failures — and an ignored test suite is worse than no test suite, because it gives false confidence.

The right shape: lots of fast unit tests for pure logic, a healthy middle layer of integration tests for "does this feature actually work end to end within the app," and a small, carefully chosen set of E2E tests only for your money-critical paths — login, checkout, the handful of journeys where "it compiles" isn't good enough proof.

Testing like a user, not like an implementation detail

👨‍🦳 Uncle: Here's the trap that catches people who do start testing. They write this:

// BAD — tests implementation details
test('sets isModalOpen state to true', () => {
  const wrapper = shallow(<OrderRow />);
  wrapper.instance().handleClick();
  expect(wrapper.state('isModalOpen')).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: What's wrong with that? It's testing the actual behavior.

👨‍🦳 Uncle: It's testing how you built it, not what it does for the user. Tomorrow you refactor from useState to a reducer, or rename the state variable — the test breaks, even though the app behaves identically from the user's point of view. That's a test actively punishing you for refactoring, which teaches your team to fear refactoring. Compare with React Testing Library's philosophy:

// GOOD — tests what a user actually sees and does
import { render, screen, fireEvent } from '@testing-library/react';

test('clicking "View Details" opens the order modal', () => {
  render(<OrderRow order={mockOrder} />);

  fireEvent.click(screen.getByText('View Details'));

  expect(screen.getByRole('dialog')).toBeInTheDocument();
  expect(screen.getByText(mockOrder.customerName)).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: This test doesn't care how the modal opens — useState, reducer, whatever?

👨‍🦳 Uncle: Exactly. It only cares that a user who clicks that button sees that modal with that data. You can refactor the internals a hundred times and this test keeps passing, because you didn't change what the user experiences. This is the single biggest mental shift in good frontend testing: query the DOM the way a user would — by visible text, by role, by label — never by internal state or CSS class names.

The integration test that would've saved your checkout bug

test('updating order status refreshes the list everywhere', async () => {
  render(<OrdersPage />, { wrapper: ReduxProvider }); // real store, real RTK Query

  await screen.findByText('Order #1234');
  fireEvent.click(screen.getByRole('button', { name: /mark paid/i }));

  await waitFor(() => {
    expect(screen.getByText(/paid/i)).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

👨‍🦳 Uncle: This one test — using your real RTK Query setup with a mocked network layer (tools like MSW, Mock Service Worker, intercept the actual fetch calls) — would have caught your checkout bug immediately, because it tests the exact chain that broke: mutation fires, cache invalidates, UI updates. This is worth more than fifty unit tests of individual helper functions.

Uncle's rule: unit test your pure logic (calculations, formatters, validators). Integration test your features (does this page actually work with the data layer). E2E test only your money paths. And never, ever test implementation details — test what a human sitting in front of the screen would notice.


Part 2: TypeScript as Architecture, Not Just "Types"

👦 Nephew: We use .jsx everywhere. Someone said we should move to TypeScript but it just seems like extra typing for no reason — like name: string everywhere.

👨‍🦳 Uncle: That's what TypeScript looks like to someone using 5% of what it's for. Let me show you the actual 95% — where it stops bugs from being possible, not just from being unnoticed.

The bug TypeScript prevents that no test catches easily

Remember RTK Query's isLoading, data, isError? In plain JavaScript, nothing stops you from writing this:

// Looks fine. Compiles fine. Explodes in production.
function OrderDetail({ data, isLoading }) {
  return <div>{data.customerName}</div>; // what if data is undefined WHILE isLoading is false, due to an error?
}
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: That's exactly the bug we had last month! "Cannot read property 'customerName' of undefined."

👨‍🦳 Uncle: Right — because in plain JS, { isLoading: false, data: undefined, isError: true } is a perfectly valid object shape that nothing warns you about. This is what I mean by illegal states being representable. TypeScript, used well, makes that state literally impossible to accidentally mishandle:

type RequestState<T> =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: T };

function OrderDetail({ state }: { state: RequestState<Order> }) {
  if (state.status === 'loading') return <Skeleton />;
  if (state.status === 'error') return <ErrorState message={state.error} />;

  // TypeScript KNOWS state.data exists here — it's literally not
  // possible to reach this line without status being 'success'
  return <div>{state.data.customerName}</div>;
}
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So it's not "does this variable have the right type," it's "is this whole shape of data even allowed to exist"?

👨‍🦳 Uncle: Now you're thinking like a TypeScript architect, not a typist. This pattern — called a discriminated union — is the single most valuable TypeScript trick for frontend architecture. Every "loading/error/success" state, every "draft/submitted/approved" order status, every multi-step wizard — model it as a union of exact possible shapes, and TypeScript will refuse to let you write code that handles a state that shouldn't exist, or forget to handle one that should.

The any house fire

function processOrder(order: any) {
  return order.amout * order.quantitty; // typo'd fields — TypeScript says nothing, `any` disables ALL checking
}
Enter fullscreen mode Exit fullscreen mode

👨‍🦳 Uncle: any isn't "no type," it's "please turn off the smoke detector in this one room." The moment you type something as any, TypeScript stops checking it entirely — typos, wrong shapes, missing fields, all invisible until runtime. At scale, with five developers, any creeping into shared code is how a "typed" codebase quietly becomes just as fragile as plain JS, except now with false confidence because everyone assumes it's checked.

Types shared with your API layer

// features/orders/api/types.ts
export interface Order {
  id: string;
  customerName: string;
  amount: number;
  status: 'draft' | 'paid' | 'refunded';
}

// Now RTK Query itself is typed end to end
getOrders: builder.query<Order[], void>({
  query: () => '/',
  providesTags: ['Order'],
}),
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So if I try to render order.statuss (typo) anywhere in the app, it just... won't compile?

👨‍🦳 Uncle: It won't even let you save the file without your editor screaming at you, red squiggly line, before you ever run the app, before a test ever catches it, before a user ever sees it. This is the real value proposition — TypeScript moves an entire category of bugs from "found in production by a customer" to "found in your editor before you finish typing the line." At 100 pages and five developers, that difference isn't convenience, it's survival.


Part 3: The List That Killed the Browser Tab — Virtualization

👦 Nephew: Sales team wants to see "all orders" — not paginated, all 50,000 of them, in one scrollable table. I just .map()'d over the array like normal. The tab froze.

👨‍🦳 Uncle: Of course it did. Let's go back to the re-render diagram from Part 1 — remember, React builds a virtual representation and reconciles it against the real DOM. Now imagine doing that dance for 50,000 real DOM nodes, all mounted at once, even though the user's screen can physically only show about 20 rows at a time.

👦 Nephew: So we're rendering 49,980 rows nobody can even see?

👨‍🦳 Uncle: Exactly — and the browser still has to lay them out, paint them, keep them in memory, and re-run all your event listeners across all of them. This is one of the most wasteful things frontend code commonly does, and the good news is the fix has a very intuitive name: virtualization, sometimes called "windowing."

The idea

Real data:  [ Row 1, Row 2, Row 3, ... Row 49,998, Row 49,999, Row 50,000 ]

                          ┌─────────────────────┐
                          │   visible viewport    │
Screen shows:             │  Row 812              │
                          │  Row 813              │
                          │  Row 814   ← only    │
                          │  Row 815    these     │
                          │  Row 816   actually   │
                          │  Row 817   exist in   │
                          │  Row 818   the DOM    │
                          └─────────────────────┘

           (Rows 1-811 and 819-50,000 are NOT rendered —
            just represented as empty space via scroll math)
Enter fullscreen mode Exit fullscreen mode

Using a library like react-window or @tanstack/react-virtual:

import { FixedSizeList } from 'react-window';

function OrdersTable({ orders }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={orders.length}
      itemSize={48}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <OrderRow order={orders[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So even though orders.length is 50,000, only the handful actually visible on screen get turned into real DOM elements?

👨‍🦳 Uncle: Correct — as the user scrolls, the library recycles the small number of DOM nodes it actually created, swapping the content shown in them, rather than creating 50,000 nodes upfront. From the user's eyes, it looks like a normal infinite table. From the browser's perspective, it's rendering maybe 20-30 rows, ever, no matter how large the dataset gets.

👦 Nephew: When do I actually need this versus just... pagination?

👨‍🦳 Uncle: Good question — don't reach for virtualization by default, it adds real complexity (fixed row heights or complex dynamic-height handling, less natural browser find-on-page, more code to maintain). Use plain pagination or infinite-scroll-with-server-fetching for most lists. Reach for true virtualization specifically when the product requirement itself demands seeing a very large in-memory dataset at once — spreadsheets, log viewers, chat histories, trading dashboards. If pagination satisfies the actual user need, it's simpler and should win.


Part 4: Forms at Scale — Beyond useState for Every Field

👦 Nephew: Our onboarding wizard has 40 fields across 5 steps. Right now it's forty useState calls and it's a nightmare to maintain, and typing feels laggy.

👨‍🦳 Uncle: Forty useState calls means forty independent pieces of state, each triggering the whole form component to re-render on every keystroke in any field — remember the re-render tree from Part 1? Every field re-render cascades down through anything below it too. On a big form, users start to feel the lag between keypress and character appearing. That's a real, measurable problem, not an imagined one.

Uncontrolled inputs — letting the DOM hold the value

👨‍🦳 Uncle: The core insight behind libraries like react-hook-form: not every keystroke needs to trigger a React re-render. Let the browser's own DOM hold the input's value (an "uncontrolled" input), and only ask React to look at the value when it actually matters — on submit, or on blur for validation.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const onboardingSchema = z.object({
  companyName: z.string().min(2, 'Company name is required'),
  email: z.string().email('Enter a valid email'),
  teamSize: z.number().min(1).max(10000),
});

function OnboardingStep1() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(onboardingSchema),
  });

  const onSubmit = (data) => {
    // data is already validated AND typed, thanks to zod + TypeScript inference
    submitStep(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('companyName')} />
      {errors.companyName && <FieldError message={errors.companyName.message} />}

      <input {...register('email')} />
      {errors.email && <FieldError message={errors.email.message} />}

      <button type="submit">Next</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So typing in companyName doesn't re-render the whole form anymore?

👨‍🦳 Uncle: Correct — react-hook-form subscribes to each field individually and keeps the bulk of typing outside React's render cycle entirely, only surfacing state (like validation errors) when needed. On a 40-field form, this is the difference between smooth typing and visible lag.

Why zod matters more than it looks

👨‍🦳 Uncle: Notice the schema isn't just for the form — it's a single source of truth for what a valid onboarding payload looks like, and TypeScript can infer the exact type from it automatically:

type OnboardingData = z.infer<typeof onboardingSchema>;
// { companyName: string; email: string; teamSize: number }
Enter fullscreen mode Exit fullscreen mode

If your backend also validates with a compatible schema (many teams now literally share the same zod schema, or a generated equivalent, between frontend validation and backend validation), you eliminate an entire category of "frontend says valid, backend rejects it" bugs — because both sides are checking the exact same rule.

👦 Nephew: So the schema is basically the contract from Part 1's normalizeOrder idea, but for what goes out instead of what comes in?

👨‍🦳 Uncle: Beautifully put. Same philosophy, opposite direction — normalize and validate at every boundary, incoming or outgoing.


Part 5: When One Team Becomes Five — Micro-Frontends & Monorepos

👦 Nephew: We're growing. Now there's a Payments team, a Growth team, an Admin team, all wanting to ship to the same app without waiting on each other or breaking each other's stuff.

👨‍🦳 Uncle: Remember the feature-folder rule from Part 1 — "only reach in through index.js"? That rule was actually training wheels for exactly this problem. Now let's talk about what happens when "features" become "separately owned, separately deployed applications."

The monorepo — same repo, clear ownership boundaries

Tools like Nx or Turborepo let you keep everything in one repository, but enforce real boundaries and give you real speed benefits:

apps/
  shell/              <- the container app, owns routing & layout
  orders-app/         <- Orders team's app
  payments-app/       <- Payments team's app
packages/
  ui-kit/             <- shared design system, owned by a platform team
  shared-types/       <- shared TypeScript types (like our Order type)
Enter fullscreen mode Exit fullscreen mode

👨‍🦳 Uncle: The magic isn't just folder organization — it's that these tools understand your dependency graph. If Payments team changes a file, Nx/Turborepo knows only payments-app and anything that actually depends on it needs to rebuild and retest — not the entire monorepo. This is what makes it viable for five teams to share one repo without CI taking 40 minutes for every single change.

Module Federation — actually shipping independently, at runtime

👦 Nephew: But if it's one build, doesn't one team still have to wait for another team's code to deploy together?

👨‍🦳 Uncle: Not necessarily — this is where Module Federation (a Webpack/Vite capability) gets interesting. It allows separate applications, built and deployed completely independently, to load each other's code at runtime, in the browser, as if they were one app.

// shell app's config — consuming remotes
federation({
  remotes: {
    ordersApp: 'ordersApp@https://orders.example.com/remoteEntry.js',
    paymentsApp: 'paymentsApp@https://payments.example.com/remoteEntry.js',
  },
});

// Using it, almost like our lazy() pattern from Part 1
const OrdersApp = lazy(() => import('ordersApp/OrdersPage'));
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So the Payments team can deploy on Tuesday, the Orders team on Thursday, and neither has to coordinate a shared release?

👨‍🦳 Uncle: Exactly — each team owns its own build, its own deploy pipeline, its own release schedule, and the shell app fetches the latest version of each at runtime. This is genuinely powerful at large-company scale.

Uncle's honest warning — don't reach for this too early

👨‍🦳 Uncle: Now, the surprise twist — I want you to actively resist this for as long as possible. Micro-frontends solve an organizational problem (independent teams stepping on each other), not a technical one, and they come with real costs: duplicated dependencies unless carefully shared (multiple copies of React shipped to the browser if misconfigured), harder cross-team debugging, versioning headaches, and genuinely difficult local development setups. If you're a single team, or even three teams who can coordinate a shared release comfortably, a well-structured monorepo with feature folders (Part 1's approach, just bigger) solves 90% of the pain with 10% of the complexity. Reach for true micro-frontends only when organizational independence, not code organization, is the actual bottleneck.


Part 6: React Server Components — The Line That Changes Everything

👦 Nephew: I keep seeing "use client" at the top of files in newer codebases. What is that?

👨‍🦳 Uncle: This is the genuinely new mental model, and it changes our Part 1 code-splitting conversation in a real way. Until now, every React component you write ends up as JavaScript shipped to the browser, hydrated, and run on the user's device — even a component that just displays static text has some JS cost. React Server Components (RSC) introduce a new category: components that render entirely on the server and send only the resulting HTML/markup to the browser — zero JavaScript for that component, ever, on the client.

Traditional React:
   Server ──(sends JS bundle)──▶ Browser ──(runs JS, renders)──▶ Screen

React Server Components:
   Server ──(renders component TO HTML directly)──▶ Browser ──(just displays it)──▶ Screen
                                                        │
                                    Only "use client" components
                                    still ship their own JS, for
                                    interactivity (clicks, state)
Enter fullscreen mode Exit fullscreen mode
// app/orders/page.jsx — a Server Component by default (Next.js App Router)
// This runs ONLY on the server. Zero JS shipped for this component.
async function OrdersPage() {
  const orders = await db.orders.findMany(); // can query the DB DIRECTLY, no API layer needed!

  return (
    <div>
      <h1>Orders</h1>
      {orders.map(order => <OrderRow key={order.id} order={order} />)}
      <MarkPaidButton /> {/* this one needs interactivity */}
    </div>
  );
}

// components/MarkPaidButton.jsx
'use client'; // <- explicitly opt IN to shipping JS, because this needs onClick, useState etc.
function MarkPaidButton() {
  const [loading, setLoading] = useState(false);
  return <button onClick={() => setLoading(true)}>{loading ? 'Saving…' : 'Mark Paid'}</button>;
}
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So by default, nothing ships JS anymore, and I have to explicitly say "this one needs to be interactive"?

👨‍🦳 Uncle: Flipped the default entirely — and that's the surprise. In Part 1, our whole code-splitting story was "reduce how much JS we send by splitting it into chunks and loading on demand." RSC asks a more aggressive question: "does this component need to send any JS to the browser at all?" A component that just displays server-fetched data with no clicks, no state, no effects — under RSC, it costs the browser nothing. Only genuinely interactive leaves of your tree — buttons, forms, toggles — need the "use client" boundary and its JS cost.

👦 Nephew: Does this replace RTK Query and our whole data-fetching story from Part 1?

👨‍🦳 Uncle: Not replace — reshape. For data that's needed to render the initial page, Server Components fetching directly (even straight from the database, no API layer needed!) is often simpler and faster than round-tripping through a client-side API call and a loading skeleton. But once the page is interactive — a user clicking "Mark Paid" and wanting the UI to update instantly, optimistic updates, background refetching — you're back in client territory, and RTK Query (or equivalent) still earns its keep there. Modern architecture increasingly mixes both: Server Components for the initial, mostly-static shell of data, client components with client-side data-fetching for the interactive, frequently-changing parts.

👦 Nephew: This feels like a genuinely different way to think about "what should even be a client component."

👨‍🦳 Uncle: It is. This is the one item on today's list that isn't just "an optimization technique" — it's an actual shift in the default assumption of what React code even is. Worth sitting with, not just memorizing.


Part 7: Security the Frontend Actually Owns

👦 Nephew: Isn't security a backend thing? Auth, permissions, that's all server-side, right?

👨‍🦳 Uncle: The backend owns authorization — deciding what a user is allowed to do. But there's a whole category of vulnerabilities that live entirely in your code, in the browser, that no backend firewall protects you from. Let's go through the real ones.

Cross-Site Scripting (XSS) — the classic

// DANGEROUS
function CommentDisplay({ comment }) {
  return <div dangerouslySetInnerHTML={{ __html: comment.text }} />;
}
Enter fullscreen mode Exit fullscreen mode

👨‍🦳 Uncle: If comment.text contains user-submitted content and you render it as raw HTML, a malicious user can submit a comment containing <script> tags or event handlers that steal other users' session data, right in their browser, the moment they view that comment. React actually protects you from this by default — normal JSX ({comment.text}) automatically escapes content. dangerouslySetInnerHTML is named that way specifically to make you stop and think before using it. Only ever use it with content that's been through a trusted sanitization library (like DOMPurify), never with raw user input directly.

Where you store auth tokens matters more than people think

👦 Nephew: We just put the JWT in localStorage after login. Is that wrong?

👨‍🦳 Uncle: It's the most common thing done "because it's easy," and it's a real risk. Anything in localStorage is readable by any JavaScript running on your page — including any XSS vulnerability anywhere in your app, or in a third-party script you loaded. If an attacker manages to inject any script at all, they can simply read localStorage.getItem('token') and steal the session completely.

The more resilient pattern: store the auth token in an httpOnly cookie — a cookie flag that makes it completely invisible to JavaScript, readable only by the browser when it automatically attaches it to requests to your server. Even a successful XSS injection can't steal a token it can't see.

👦 Nephew: So the frontend deliberately can't touch its own auth token?

👨‍🦳 Uncle: Exactly the point — the inability to read it is the security feature. Combine this with a Content Security Policy (CSP) header — a rule your server sends telling the browser "only execute scripts from these trusted sources, nowhere else" — and you've closed off the two biggest doors an attacker would try.

Trust nothing from the URL

// DANGEROUS — never trust query params or route params as safe/validated
const redirectUrl = new URLSearchParams(window.location.search).get('redirect');
window.location.href = redirectUrl; // open redirect vulnerability — attacker crafts a link
                                      // that looks like your domain but redirects to a phishing site
Enter fullscreen mode Exit fullscreen mode

👨‍🦳 Uncle: Anything coming from the URL — query params, route params, even the referring page — is user-controlled input, exactly as much as a form field is. Validate it, allow-list it (only redirect to known internal paths, for instance), never pass it straight through to something sensitive like a redirect or, worse, directly into dangerouslySetInnerHTML.

Uncle's summary: the frontend can't stop a determined attacker from everything — that's genuinely the backend's job for authorization — but XSS prevention, safe token storage, and URL/input distrust are 100% frontend responsibilities that no backend can save you from.


Part 8: Accessibility — The Feature Nobody Demos, Everyone's Sued Over

👦 Nephew: Uncle, be honest — does accessibility actually matter for a normal business app, or is it a checkbox for government sites?

👨‍🦳 Uncle: Let me answer with a story instead of a lecture. A screen reader user tries to use your onboarding form. Every input has a placeholder but no actual <label>. The screen reader announces "edit text, blank" for every single field — no idea what to type. They give up and call support, or worse, they can't use your product at all. That's not an edge case to that user — that's the whole experience. And yes, there's also a very real legal dimension (ADA lawsuits against inaccessible websites are common and expensive) — but the deeper truth is accessibility and good architecture are the same discipline wearing different clothes.

The <div onClick> sin

// BAD — looks like a button, isn't one
<div onClick={handleSubmit} className="btn">Submit</div>
Enter fullscreen mode Exit fullscreen mode

👨‍🦳 Uncle: This is invisible to a keyboard-only user (can't Tab to it, can't press Enter to activate it) and invisible to a screen reader (announces as "text," not "button"). The fix costs you nothing:

// GOOD — free keyboard support, free screen reader semantics
<button onClick={handleSubmit}>Submit</button>
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So half of "accessibility" is just... using the right HTML element instead of a styled div for everything?

👨‍🦳 Uncle: A huge chunk of it, yes — semantic HTML gets you most of the way for free. Use <button> for things you click, <a> for things that navigate, real <label> elements tied to real <input> elements, <nav>/<main>/<header> landmarks so screen reader users can jump around your page structurally instead of reading it top to bottom every time.

The parts that need real intention

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef();

  useEffect(() => {
    if (isOpen) modalRef.current?.focus(); // move focus INTO the modal when it opens
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      tabIndex={-1}
      onKeyDown={(e) => e.key === 'Escape' && onClose()}
    >
      <h2 id="modal-title">Order Details</h2>
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

👨‍🦳 Uncle: Notice what's happening: role="dialog" and aria-modal="true" tell assistive technology "this is a modal, treat it specially." Moving focus into it when it opens means a keyboard user isn't left stranded, still tabbing through content behind the modal they can't even see. The Escape key handler means a keyboard user can close it without hunting for a mouse-only close button.

👦 Nephew: This actually connects to our Error Boundary and form-error discussions from earlier, doesn't it? Making failure states legible, but now legible to everyone, not just sighted mouse users.

👨‍🦳 Uncle: Exactly the insight I was hoping you'd land on. Good architecture keeps asking "what if this consumer isn't who I assumed" — a different backend shape, a different team, a different browser, and now, a different way of perceiving and navigating your app entirely. It's the same muscle, pointed at a new kind of "what if."


Part 9: Watching It in the Wild — Real User Monitoring

👦 Nephew: We optimized everything — skeletons, memoization, code splitting. How do we actually know it helped real users, not just our laptops on fast WiFi?

👨‍🦳 Uncle: This is the piece that closes the loop from Part 1's "ship, measure, repeat," and it's the one juniors skip most often, because it feels like "someone else's job" — but it's yours, because you're the one who can act on what it tells you.

Core Web Vitals — Google's (and reality's) scorecard

  • Largest Contentful Paint (LCP) — how long until the main, largest piece of content is visible. This is where your Part 1 image optimization and skeleton work directly shows up as a number.
  • Interaction to Next Paint (INP) — how responsive the page feels when a user actually clicks or types, replacing the older "First Input Delay" metric. This is where your re-render optimization (memo, useCallback) and avoiding event-loop-blocking work pays off measurably.
  • Cumulative Layout Shift (CLS) — how much content jumps around unexpectedly. This is exactly the metric your reserved image dimensions and skeleton shapes from Part 1 were protecting.

👦 Nephew: So these three numbers are basically a report card for everything we built in Part 1?

👨‍🦳 Uncle: Precisely — and the crucial point is these need to be measured from real users' actual devices and networks (this is called Real User Monitoring, RUM), not just your development machine. A junior developer testing on a new laptop with fiber internet has no idea what your app feels like on a three-year-old phone on patchy 4G — and that gap is often where your real users live.

Error tracking — catching what your Error Boundaries saw

componentDidCatch(error, info) {
  Sentry.captureException(error, { extra: info }); // ties back directly to Part 1's Error Boundaries
}
Enter fullscreen mode Exit fullscreen mode

👨‍🦳 Uncle: Remember the Error Boundary from Part 1 that stopped one broken room from burning the whole house? Without monitoring wired into it, you close the door on the fire — but you never find out the fire happened at all, until a user complains, if they even bother to. A tool like Sentry (or similar) captures the actual stack trace, the browser, the user's session context, and alerts your team the moment it happens — turning "silent failures nobody reports" into "we knew about this bug nine minutes after the first user hit it."

Session replay — seeing exactly what the user saw

👨‍🦳 Uncle: The most humbling tool in this category — session replay tools record a (privacy-respecting, usually masking sensitive fields) reconstruction of exactly what a user did: where they clicked, where they got confused, where they rage-clicked a button that didn't respond. You will learn more about your real UX problems watching five of these than reading fifty analytics dashboards.

Closing the loop, for real this time

Ship a change (e.g. added skeleton screens)
        │
        ▼
RUM shows LCP/CLS improve for real users, not just your laptop
        │
        ▼
Error tracking shows whether the change introduced any new failures
        │
        ▼
Session replay shows whether users actually behave differently now
        │
        ▼
Feed findings back into the NEXT thing you optimize
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So this isn't a "final step," it's actually the step that tells us what Part 3 should even be about.

👨‍🦳 Uncle: Now you understand why I never call any of this "done." Architecture isn't a destination, it's a feedback loop — you build, you isolate change, you measure blast radius, you observe real behavior, and the next problem tells you exactly where to look next. The tools change — testing, types, virtualization, micro-frontends, RSC, security, accessibility, monitoring — but the underlying discipline is always the same one from Part 1: isolate what's likely to change or break, and build a way to see it when it does.


Uncle's Closing Words, Part 2

👨‍🦳 Uncle: You started this conversation thinking architecture was about folders and file structure. Now you've seen it show up in nine completely different disguises — in tests that describe user behavior instead of implementation, in types that make bad states impossible, in virtualized lists that respect the browser's limits, in forms that don't fight React's render cycle, in teams that can ship independently, in a server/client boundary that questions whether JS needs to exist at all, in the quiet discipline of not trusting a single string that comes from outside your app, in a <button> instead of a <div>, and in the humility to watch what real users actually experience instead of assuming your laptop is the truth.

👦 Nephew: So what's Part 3?

👨‍🦳 Uncle: (grins) That depends entirely on what breaks next. That's the job.


End of chat. Go write one integration test before you touch anything else.

Top comments (0)