cf.completefrontend
My progress
SYSTEM DESIGN · #83

Design LinkedIn

The web client of a professional network: public profiles that search engines index and guests open from a link, rich profiles that members edit section by section, a graph of connections with privacy rules, and messaging, notifications, and jobs in one app shell. The hard parts are serving one URL to two very different audiences, never sending a viewer data they may not see, and editing many sections safely.

The question

Design the web client for LinkedIn. Members have a profile with many sections (experience, education, skills, recommendations), a connections graph, a home feed, messaging, notifications, and a jobs area with Easy Apply. Anyone can open a member's public profile at a link like /in/ana-ruiz from a Google search, without signing in.

Members decide who sees what: some fields are visible only to connections, some profiles are hidden from search, and people can browse other profiles in private mode. Walk me through how the same profile URL works for a crawler, a signed-out visitor, a stranger, a connection, and the owner, how the owner edits it, and how messaging and badges live in the shell around every page.

Real-life examples: LinkedIn, Xing, Indeed profiles and jobs, GitHub user pages, Wellfound

Requirements: the questions that shape a professional network

"Design LinkedIn" can sound like "design a feed with a profile page". The feed part is mostly solved: the News feed, Twitter, and Instagram guides cover ranking, cursor paging, and media. What makes LinkedIn its own problem is the profile: it is a public web page that strangers find from Google, a private app screen for the people you know, and a large form that its owner edits, all at the same URL.

So the first five minutes go to who visits, what they may see, and what the owner can change. Each answer moves the design. The table shows what this guide assumes and what changes if the interviewer says something else.

QuestionAnswer we assumeWhat it changes
Who opens profile pages?A large share of profile views come from search engines and shared links, by people who are not signed in. Crawlers fetch millions of profiles a day.Public profiles must be complete HTML from the server, fast on a phone, and cacheable at the edge. A client-only app fails here.
How many members and profiles?Over 1 billion members. Hundreds of millions have public profiles.We can't pre-build every page. Pages render on demand and the CDN caches the popular ones.
How big is one profile?Most have 3 to 10 positions and 10 to 30 skills. Some have 30 positions, 50 skills, and 80 recommendations.The page shows the first few items of each section and a "Show all" page per section. Below-the-fold sections load late.
What privacy settings exist?Per-field audiences (photo, email, connections list), a whole-profile public switch, private browsing mode, and blocking.The server builds a per-viewer view. The client never receives a hidden value. Guest HTML must never contain member-only fields.
Can the owner edit in place?Yes: each section has its own edit form and save. Items can be added, edited, removed, and reordered.Section-level form state, optimistic saves, and rollback that touches one section only.
Messaging and notifications?Yes, but in the same app shell: a chat overlay docked at the bottom right and badges in the top bar.A persistent shell around routed pages, one live connection per browser. Chat internals follow the Chat app and Slack guides.
Jobs?Search with filters, saved jobs, and Easy Apply with a resume upload.URL-driven search state, a multi-step form, and a file upload. Job pages are public and indexable too.
Devices and networks?Many members use mid-range Android phones on slow or flaky mobile networks, in many countries.Strict JavaScript budgets, server-rendered first paint, small images, and i18n with right-to-left languages.

Functional requirements

  • Show a profile at /in/vanity-name: a top card (photo, name, headline, location, connection degree) and sections for about, experience, education, skills, recommendations, and activity, each with a "Show all" page.
  • Serve that same URL to crawlers and signed-out visitors as a public profile with limited fields and a sign-in prompt, and to members as the full app view for that viewer.
  • Let the owner edit each section in place: add, edit, delete, and reorder items, with validation, and set visibility options like "Open to work" and per-field audiences.
  • Show the right action for each viewer: Connect, Pending, Message, Follow, or Sign in. Send invitations with an optional note; accept or ignore incoming ones; show mutual connections and people you may know.
  • Keep a messaging overlay open while the member moves between pages, and show live unread counts for messages, notifications, and invitations in the top bar.
  • Show a home feed with posts that hold text, images, articles, documents, and polls, with several reaction types and reshares.
  • Let members search jobs with filters, save jobs, and apply with a short multi-step Easy Apply form that includes a resume upload.

Non-functional requirements

  • Privacy first: no value reaches a browser whose viewer may not see it. Hiding with CSS or client code does not count. Cached pages never mix one viewer's data into another's.
  • Fast public pages: Largest Contentful Paint (LCP, the time until the biggest visible element shows) under 2.5 s at the 75th percentile on mobile, and under 100 KB of compressed JavaScript on the guest profile.
  • Fast member pages on weak phones: Interaction to Next Paint (INP, the delay between a tap and the next frame) under 200 ms on a mid-range Android phone, even with the overlay and badges running.
  • Indexable: public profiles and public job posts are crawlable, with one canonical URL each; profiles hidden from search are noindex and stay out of the cache.
  • Fresh enough: a privacy change or a profile edit reaches the cached guest page within about a minute, and within seconds for privacy tightening.
  • Accessible and global: every form works with a keyboard and a screen reader, the overlay never traps focus, and the UI works in dozens of languages including right-to-left ones.

Out of scope

  • Feed ranking and paging internals: the server ranks; the News feed guide covers the client side. We cover only what LinkedIn posts add.
  • Messaging internals (ordering, delivery receipts, offline outbox): see the Chat app and Slack guides. We cover how chat lives inside the shell.
  • Recruiter tools, ads, LinkedIn Learning, and premium analytics: separate products with their own designs.
  • The graph backend: computing 2nd and 3rd degree paths is a server problem. The client only shows the answer.

Check your understanding · Exercise 1

Which answers from the interviewer change the frontend design the most? Select all that apply.

What makes LinkedIn different from the feeds you already know

Twitter and Instagram are feed apps with profiles attached. LinkedIn is closer to the opposite: a directory of professional profiles with a feed attached. Five traits drive every decision in the rest of this guide.

  1. One URL, many audiences. /in/ana-ruiz is opened by Googlebot, by a recruiter who is not signed in, by a stranger who is signed in, by Ana's 2nd-degree contacts, by her connections, and by Ana herself. Each must get different HTML, and some of those versions are shared by millions of people while others are private to one.
  2. Privacy is part of the render, not a filter on top. Whether Ana's email or connection list appears depends on the relationship between viewer and owner. That relationship (1st, 2nd, 3rd degree, blocked) is computed from a graph of over a billion nodes. The client cannot compute it and must not be trusted with the data needed to compute it.
  3. The profile is also a big form. The owner edits ten or more sections, each with its own items, validation, ordering, and visibility. A failed save in one section must not disturb edits in progress in another.
  4. A persistent shell. Messaging, notifications, and invitations live in a shell around every page: a top bar with live badges and a chat overlay that stays open while the member moves from a profile to a job to the feed.
  5. Weak devices, slow networks, many languages. A large share of members are on mid-range Android phones in markets with slow mobile data, and they read the site in dozens of languages, some right-to-left. The public profile is often their first contact with the product.

Put those together and you get the tension at the center of the design. The public profile wants to be a document: plain server HTML, identical for everyone, cached at the edge for minutes, with almost no JavaScript. The member experience wants to be an application: personalized, live, with client-side navigation so the chat overlay survives. Most of this guide is about getting both from the same route without leaking data between them.

TraitTwitter or InstagramLinkedIn
Main entry pointThe home feed, signed inA public profile or job page, often signed out, from search
What decides what a viewer seesMostly public or private accountPer-field audiences plus graph degree plus private mode
Main write pathPost a tweet or photoEdit one of many profile sections; send an invitation
Relationship modelOne-way followTwo-way connection with an invitation step, plus one-way follow
SEO weightSome (public posts)Very high: profiles and jobs are search landing pages

Think first

Before reading on: if you could build only one thing first, the guest public profile or the signed-in member profile, which would you pick, and why?

One URL, two audiences: rendering and caching the profile route

Start with the naive answer most people give: "it's a React single-page app". The server sends an empty div and a 600 KB bundle; the bundle fetches the profile JSON and renders it. Signed-in members on a fast laptop are fine.

Now follow a recruiter in Lagos who taps a Google result on a mid-range phone over a slow 4G link. The HTML arrives fast but is empty. The bundle takes about 3 s to download and another 1 to 2 s to parse and run on that CPU, and only then does the profile request start, so the name appears after 5 or 6 s. Many visitors leave before that.

Search is hurt too. Googlebot does render JavaScript, but as a separate step after crawling that can be delayed, and many other crawlers and link-preview bots don't run JavaScript at all. Content that exists only after scripts run is indexed later and less reliably than plain HTML.

The second naive answer is "server-render everything, per request". Now the recruiter gets HTML with the name in it. But every one of millions of daily crawler and guest hits runs a full personalized render, calls the graph service, and returns a page no cache can share.

It is also one header away from disaster. If anyone ever adds Cache-Control: public to that route to save cost, a member's personalized page (with their own name in the top bar, or a connection's email) can be cached and served to strangers. That is the worst bug this product can have.

The way out is to admit that the URL has two products behind it. Guests (and crawlers, which are just guests that read HTML) all get the same page for a given profile and language, so that page can be cached. Members each get a different page, so theirs is never cached by anything shared. The rendering lessons (Server-side rendering, Streaming server rendering, Choosing a rendering strategy) cover the techniques; here we decide how to combine them.

Decision How should /in/ana-ruiz be rendered for guests, crawlers, and members?

Client-side app for everyone

Serve an empty HTML shell and the app bundle.

The client fetches the profile and the viewer relationship and renders.

Pros

  • One code path.
  • Simple hosting: static files plus an API.

Cons

  • Slow first paint on weak phones: LCP waits for JavaScript and then for data.
  • Weak for search: content depends on rendering JavaScript.
  • The JSON API must be callable by guests, which makes scraping easy.

Server-render per request for everyone

Every request runs the full app on the server for that viewer.

No shared caching.

Pros

  • Fast first paint, content in the HTML.
  • One code path.

Cons

  • Every guest and crawler hit costs a full render and graph lookup.
  • One wrong cache header leaks personal pages.
  • Origin load spikes when a profile goes viral.

Pre-render every public profile (static generation)

Build HTML for each public profile ahead of time and store it on the CDN.

Rebuild a page when its profile changes.

Pros

  • The fastest possible guest response.
  • No render cost on the hot path.

Cons

  • Hundreds of millions of pages, most rarely visited: wasted builds.
  • Every edit and privacy change needs a rebuild, and privacy changes must be fast.
  • Members still need a separate personalized path.

Split by session: cached guest render, private member render

The edge checks for a session cookie.

No session: render the public view on demand, cache it at the edge per profile and language, purge on change.

Session: stream a private, uncached render for this viewer, then hydrate into the client app.

Pros

  • Guests and crawlers get edge-cached HTML in tens of milliseconds.
  • Only popular profiles are cached; rare ones render on demand.
  • Members get a personalized page, and the cache rules for each path are simple and separate.

Cons

  • Two variants of the page to build and test.
  • Purging the guest cache on edits needs tagging.
  • The edge must route correctly: a bug here is a privacy bug.

What we pick: Split by session. The guest version is a document: one per profile and language, cacheable, almost no JavaScript. The member version is an application: rendered for one viewer, never stored in a shared cache, then interactive. Pre-rendering is the same idea as the guest path but done eagerly for pages nobody asked for; rendering on first request and caching gets the benefit without the waste.

How one profile URL reaches two audiences. A crawler, a guest, and a member all request /in/ana-ruiz and reach the edge router, which only checks whether a session cookie exists. Without a cookie the request goes to the edge cache, which stores guest HTML with s-maxage 600; on a miss it asks the guest renderer for the public view. With a cookie the request goes to the member renderer, which streams a private page with no-store. Both renderers ask the Profile BFF for a projection: for the guest renderer the viewer is guest, for the member renderer it is the signed-in member, here Bob.crawlerguestmemberEdge routercookie exists?Edge caches-maxage=600no cookieGuest rendererislands onlymissMember rendererstream, no-storecookieProfile BFFprojectionviewer: guestviewer: Bob
The edge decides before any cache lookup. The guest path is shared and cached; the member path never touches a shared cache. Both get their data from the same BFF projection, only for different viewers.

Routing at the edge

The CDN runs a small edge function in front of the origin. It does not decode the session token (that needs keys and a call to the auth service). It only asks one cheap question: is there a session cookie at all? If not, the request is a guest request and the cache key is the path plus the language.

If there is a cookie, the request bypasses the shared cache and goes to the member renderer, which validates the session properly (see the Authentication and token storage lesson). A forged or expired cookie just lands there too. The member renderer clears it with a Set-Cookie that expires it and serves the guest view uncached, or redirects to sign-in. Redirecting to the same URL would loop, because the bad cookie would still be sent.

ts
export default async function handle(req: Request, env: Env) {
  const url = new URL(req.url);
  const hasSession = /(?:^|;\s*)session=/.test(req.headers.get("cookie") ?? "");
  if (hasSession) {
    // Member path: private render, never stored in a shared cache.
    return fetch(new Request(env.MEMBER_ORIGIN + url.pathname + url.search, req));
  }
  const lang = pickLanguage(url, req.headers.get("accept-language"));
  const key = `https://guest-cache${url.pathname}?lang=${lang}`; // no cookies, no query noise
  const cached = await env.CACHE.match(key);
  if (cached) return cached;
  const res = await fetch(`${env.GUEST_ORIGIN}${url.pathname}?lang=${lang}`);
  // Never store a response that sets a cookie: it would hand one visitor's cookie to all.
  if (res.ok && res.headers.get("x-cacheable") === "1" && !res.headers.has("set-cookie")) {
    await env.CACHE.put(key, res.clone()); // tagged by the origin, e.g. "profile-8812"
  }
  return res;
}
Edge routing: the cache key never contains anything personal.
The headers for each path. s-maxage is for shared caches; max-age=0 makes the browser revalidate, so a member who just signed in gets their own page, not the guest copy from the browser cache. stale-while-revalidate is left out of Cache-Control on purpose: it applies to the browser too. If the edge should serve stale copies while it refetches, put that in an edge-only header such as CDN-Cache-Control.
PathCache-ControlWhy
Guest profile HTMLpublic, max-age=0, s-maxage=600Edge keeps it 10 minutes; edits purge it sooner by tag.
Member profile HTMLprivate, no-storePersonal. No cache, shared or in the browser, may keep it.
Profile hidden from guestsprivate, no-store plus X-Robots-Tag: noindexThe guest response is a short "sign in to view" page, never cached as the profile.
Hashed JS and CSSpublic, max-age=31536000, immutableFile names change on every deploy; the CDN caching lesson covers why.

Purging. The guest origin tags each response with a surrogate key such as profile-8812, and when Ana edits a section, the profile service emits an event and a worker purges that tag across the CDN, usually in a few seconds. Privacy tightening (making the profile non-public) writes the setting, purges, and only then tells the owner it is done. Purging before the write would leave a gap where a guest request re-caches the old public page. Loosening can wait for the normal expiry.

The member render: streamed, then an app

For members, the server streams the page (see the Streaming server rendering lesson). It sends the app shell (top bar, navigation) and the top card first, because the top card is the LCP element and needs only one fast lookup. Sections arrive as later chunks inside Suspense boundaries as their data resolves. Recommendations, which need several services, never hold up the name and photo.

Then the page hydrates into the member client app, so the next click to a job or another profile is a client-side navigation that fetches JSON and keeps the messaging overlay alive. The guest page is different: it hydrates only a few islands (the sign-in button, "Show more" toggles) and ships no router at all (see the Islands and partial hydration lesson). Same components on the server, two different client bundles.

Check your understanding · Exercise 2

A teammate proposes caching the member profile page at the CDN with Vary: Cookie so repeat views are faster. What is wrong with it? Select all that apply.

Privacy in the UI: build a view per viewer, never hide with CSS

Ana has set her email to "connections only" and her connection list to "only me". The naive build fetches her whole profile as JSON and decides in the component: if (viewer.isConnection) show email. On screen it looks right. But open DevTools on Bob's browser (Bob is a stranger) and the network tab shows the full JSON with her email in it.

With server rendering the leak is quieter but just as real. The framework serializes the props it used to render into the page (the hydration data), so a hidden field can sit in a script tag even though no element shows it. And if that page is a cached guest page, every visitor and every scraper gets it.

The rule that fixes all of this is short: the server decides, the client only displays. A value the viewer may not see is never in any response that viewer gets: not in HTML, not in JSON, not in hydration data, not in a JSON-LD block, not in an image URL. The client gets a ProfileView: the profile as this one viewer may see it.

Decision Where should field visibility be enforced?

In the client components

Send the full profile.

Components check the viewer's relationship and hide fields.

Pros

  • Simple to build.
  • One response shape for everyone.

Cons

  • Hidden data is in the network response and the page source.
  • Any new component that forgets the check leaks.
  • Cannot be cached for guests without leaking to all of them.

Per-viewer projection on the server (BFF)

The backend for frontend (BFF) loads the profile and asks the graph service for the relationship.

A policy maps relationship and owner settings to a list of allowed fields and actions.

It returns only those fields, plus small markers for fields that exist but are restricted.

Pros

  • The client cannot leak what it never got.
  • One policy, tested once, used by SSR and by JSON APIs.
  • The guest projection is a fixed tier, so it caches.

Cons

  • A graph lookup on every member view (usually cached for minutes).
  • Response shape differs by viewer, so client types must allow missing fields.

A few precomputed audience copies

Store public, members, network, and connections copies of each profile.

Pick a copy by relationship at request time.

Pros

  • Very fast reads.
  • Easy to cache each tier.

Cons

  • Four copies to keep in sync on every edit and setting change.
  • Per-field settings multiply the copies (photo public, email connections only...).
  • Viewer-specific parts (degree, mutual connections, actions) still need a live step.

What we pick: Per-viewer projection in the BFF. It is the only option where the client cannot get a hidden value by mistake. The cost, a relationship lookup per member view, is small next to a privacy incident. The guest projection is just the projection for the "no viewer" relationship, so it falls out for free and is the one we cache.

The privacy pipeline in the BFF. Three inputs, the stored profile, the graph service with degree and blocks, and the owner's settings with field audiences, flow into one projection step. The projection marks each field as shown, restricted with a marker, or missing. Its output feeds three places: guest HTML that is cached, member HTML that is no-store, and the JSON API used by client navigation.InputsProfileall fieldsGraphdegree, blocksSettingsfield audiencesProjectionBFF policyshown: the valuerestricted: a markeronly me: missingOutputsGuest HTMLedge cachedMember HTMLprivate, no-storeJSON APIclient navigation
Every output is built from the projection, never from the raw profile. That is why the guest cache, the hydration data, and the JSON API cannot leak a field: the value is gone before any of them sees it.

How the projection works

The BFF first works out the relationship: self, first, second, third (3rd degree and beyond), member with no path, guest, or blocked. Blocked stops before any projection (see the warning below). Then, for each field, it compares the relationship with the owner's audience setting for that field. Audiences are ordered from wide to narrow (public, all members, your network up to 2nd degree, connections, only you), and a field is shown if the viewer's relationship is inside the audience.

ts
type Rel = "self" | "first" | "second" | "third" | "member" | "guest";
type Audience = "public" | "members" | "network" | "connections" | "me";

const allowed: Record<Audience, Rel[]> = {
  public: ["self", "first", "second", "third", "member", "guest"],
  members: ["self", "first", "second", "third", "member"],
  network: ["self", "first", "second"],
  connections: ["self", "first"],
  me: ["self"],
};

type Field<T> = T | { restricted: Audience } | undefined;

function project<T>(value: T, audience: Audience, rel: Rel): Field<T> {
  if (allowed[audience].includes(rel)) return value;
  if (audience === "me") return undefined;  // don't even admit it exists
  return { restricted: audience };          // UI can say "Connect to see"
}
The projection: a field is either present, marked restricted, or missing.

The middle state matters for the UI. A restricted email comes back as { restricted: "connections" }, so the contact card can say "Connect with Ana to see her email" without holding the email. A field whose audience is "only me" comes back missing, because even saying it exists tells the viewer something.

Degree badges (1st, 2nd, 3rd next to the name) come from the same relationship. The client could never compute them: a 2nd-degree check needs both people's connection lists, and sending those to a browser would be a leak in itself. The view also carries a mutual connections summary (a count and up to three names the viewer is allowed to see) and the actions this viewer may take: connect, withdraw, message, follow, or signIn. The client renders buttons from that list instead of re-deriving rules.

Viewer
Ana's profile
What the server sends for Ana's profile to this viewer: signed out, with the profile public. 3 of 8 fields are sent. name, headline: Ana Ruiz, Staff Engineer. photo: photo, 200 px. experience: 3 of 9 positions. open to work: not sent. degree badge: not sent. mutual: not sent. email: restricted: connections. connections list: not sent. Action: Sign in. Served from edge cache, guest key, Cache-Control public, s-maxage=600, robots index + canonical.fieldwhat this viewer is sentstatusname, headlineAna Ruiz, Staff Engineersentphotophoto, 200 pxsentexperience3 of 9 positionssentopen to worknot sentnot sentdegree badgenot sentnot sentmutualnot sentnot sentemailrestricted: connectionsmarkerconnections listnot sentnot sentaction: Sign infields sent: 3 of 8robots: index + canonicaledge cache, guest keyCache-Control:public, s-maxage=600
One cached copy for every guest. Only public fields exist in it, so it can be shared.

Who viewed your profile, and private mode

When Bob opens Ana's profile, the server records a view. Bob chooses how he appears in Ana's "Who viewed your profile" list. LinkedIn offers three modes: his name and headline, private profile characteristics ("Someone at Acme"), or private mode, where he shows up only as "LinkedIn Member". On a free account, browsing in private mode also hides who viewed Bob's own profile, so the setting has a cost the UI must state.

That setting belongs to Bob, the viewer, and the server applies it when it stores the view. The client sends nothing special. A design where Bob's browser decides whether to send his identity would let anyone with a modified client stalk without being seen, or worse, be seen when they chose not to be.

Two details catch people out. First, the list Ana sees is itself a projection: private viewers arrive with no id at all, not an id plus a hidden: true flag. Second, analytics and logging on the client must not quietly record Bob's member id alongside "viewed profile 8812" in a third-party tool when he is in private mode. The safest pattern is that the client analytics event carries only the page, and the server joins identity under the same rules.

  • Hydration data: only the projected view is passed to client components. Server components can read raw data, but anything handed to a client component is serialized into the page.
  • Structured data and meta tags: the guest page's JSON-LD, og:image, and description are built from the guest projection, never from the full profile.
  • Images: a photo restricted to connections is served from a signed, short-lived URL that only member views include. A stable public URL would let anyone fetch it after one leak.
  • Caches: the guest projection is cached; member projections are private. A client-side query cache is cleared on sign-out, so the next person at a shared computer sees nothing of the last.

Check your understanding · Exercise 3

Ana's connection list is set to "only me". Which implementations are safe? Select all that apply.

The profile page: many sections, loaded in the right order

A profile is a column of sections under a top card: About, Featured, Activity, Experience, Education, Licenses, Skills, Recommendations, Languages, Interests, and more, plus a side rail with "People also viewed". A heavy profile has 30 positions with long descriptions, 50 skills with endorsement counts, and 80 recommendations. Rendering all of it on first load would mean a 300 KB JSON response and thousands of DOM nodes before anything is interactive, on a phone that may handle a third of that comfortably.

Wireframe of a member viewing /in/ana-ruiz. A top bar with Home, My Network, Jobs, Messaging with 3 unread, and Notifications with 12. The main column has the top card with photo, name, a 2nd degree badge, headline, and Connect and Message buttons; About showing the first 300 characters; Experience showing 3 of 9 positions with a Show all 9 link; Skills; and Activity, which is deferred. The side rail has People also viewed, deferred, and the messaging overlay docked at the bottom right, which stays open across pages./in/ana-ruizHome, Network 1, Jobs, Messaging 3, Alerts 12Top card: photo, name, 2ndheadline, Connect, MessageAbout: first 300 charsExperience: 3 of 9Staff EngineerShow all 9Skills: 3 of 24Activity: deferredPeople alsovieweddeferredpencils: owneronly, lazy chunkMessagingstays across pages
The main column is server-rendered summaries; the dashed parts load later. The top bar and the messaging overlay belong to the shell, not to this page, so they survive navigation.

Two ideas keep this under control. First, the profile page shows a summary of each section: the first 3 to 5 items and a total, with "Show all 30 experiences" linking to a section page at its own URL (like /in/ana-ruiz/details/experience/) with cursor paging. The full lists never load on the main page. Second, sections are not all equal: some are cheap text that the guest page and search engines need, some are expensive or personal and can come later.

Decision How should the profile sections load?

Everything in one response

One request (or one server render) fetches every section in full.

Pros

  • Simple. One loading state.

Cons

  • The slowest section (activity, recommendations) delays the whole page.
  • Large payload and DOM on weak phones.
  • Wasted work: most visitors never scroll to Interests.

Each section fetched by the client when scrolled near

The server sends only the top card.

An IntersectionObserver per section triggers its fetch.

Pros

  • Small first response.
  • Only what is seen is loaded.

Cons

  • Guests and crawlers get a nearly empty profile.
  • Scrolling fast shows a row of spinners.
  • Many small requests on a high-latency network.

Server-render text summaries, defer the heavy parts

Stream summaries of the text sections (about, experience, education, skills) in the HTML.

Load activity, "People also viewed", and recommendation details on the client when near the viewport.

Code-split edit forms; load them only for the owner on first edit.

Pros

  • Guests and crawlers get the content that matters for search.
  • The first render is bounded: summaries are small.
  • Personal and costly parts never delay the top card.

Cons

  • Two loading styles to build.
  • Needs a clear rule for which sections are "heavy".

What we pick: Server-render the summaries, defer the heavy parts. A summary profile is about 15 to 30 KB of HTML after compression and is what a recruiter or crawler wants. Activity posts (images, reactions), the side rail, and edit forms are the costly parts, and they are below the fold or owner-only, so they load when needed.

Timeline of a profile load on a mid-range phone, from 0 to 2.5 seconds. The first HTML chunk with the shell and top card arrives by 0.35 seconds. The profile photo, the LCP element, loads from 0.35 to 1.0 seconds. The section summaries stream from 0.35 to 0.8 seconds. Hydration runs from 0.9 to 1.6 seconds. Activity loads at 1.8 to 2.3 seconds, only when scrolled near. The editor chunk loads at 2.2 seconds, only when the owner clicks a pencil.shell + top cardfirst chunkphoto (LCP)LCP at 1.0 ssectionsstreamed summarieshydrateapp or islandsactivityscrolled neareditor chunkowner clicks a pencil0 s0.5 s1 s1.5 s2 s2.5 s
Nothing below the fold or owner-only competes with the top card and its photo. Activity waits for IntersectionObserver; the editor waits for a click.

A registry of section types

The server decides which sections exist and their order (owners can't reorder sections, but the product adds new ones often). The client keeps a registry from section type to component and skips types it does not know. That way a server can start sending a new volunteering section before every client version has the component, and old clients still render the rest. Each entry also says whether the section is server-rendered or deferred, and which chunk holds its editor.

tsx
const registry: Record<string, SectionEntry> = {
  about: { View: AboutSection, deferred: false },
  experience: { View: ExperienceSection, deferred: false },
  skills: { View: SkillsSection, deferred: false },
  activity: { View: lazy(() => import("./ActivitySection")), deferred: true },
};

function Sections({ view }: { view: ProfileView }) {
  return view.sections.map((s) => {
    const entry = registry[s.type];
    if (!entry) return null; // a newer server, an older client
    const body = <entry.View summary={s} canEdit={view.actions.includes("edit")} />;
    return entry.deferred
      ? <WhenNearViewport key={s.type} margin="600px" fallback={<SectionSkeleton rows={2} />}>{body}</WhenNearViewport>
      : <Fragment key={s.type}>{body}</Fragment>;
  });
}
Render known sections, skip unknown ones, defer the heavy ones.

WhenNearViewport is a small wrapper around IntersectionObserver with a rootMargin of about 600 px, so the fetch starts roughly one screen before the section scrolls in. The skeleton has the same height as a typical section so the page does not jump when content arrives, which keeps Cumulative Layout Shift (CLS) near zero. For long descriptions, the summary sends the first 300 characters plus a "see more" toggle; expanding is a client toggle on text that is already in the HTML, so it needs no request and works for crawlers.

Think first

Why give each "Show all" list its own URL instead of expanding it in place on the profile?

Check your understanding · Exercise 4

Which sections are good candidates to defer to the client until they are near the viewport? Select all that apply.

Editing in place: one section at a time, saved optimistically

When Ana views her own profile, every section shows a pencil. She edits a position's title, adds a skill, reorders her Featured items, turns on "Open to work". The naive build has one big "Edit profile" form that PUTs the whole profile on save.

Three things go wrong. A typo in the education end date blocks saving an unrelated change to the headline. Two tabs (or the phone app and the laptop) each send the whole profile, and the later save silently wipes the other's changes. And a single server error loses everything she typed in the last ten minutes.

The fix is to make the section item the unit of editing: one position, one education entry, the headline, the skills list. Each has its own form, its own validation, its own save, its own version, and its own failure. The Forms at scale and Optimistic updates lessons cover the general techniques; here is how they fit together for a profile.

Decision How should profile edits be saved?

One form for the whole profile

An edit mode for the whole page.

One save sends the full profile with PUT.

Pros

  • One code path for saving.
  • Easy to show "unsaved changes".

Cons

  • One invalid field blocks every other change.
  • Whole-document writes overwrite edits from another tab or device.
  • Large requests on slow networks; one failure loses everything.

Per-item forms, wait for the server

Each item opens its own form (a dialog on mobile, inline on desktop).

Save shows a spinner and the form closes after the server confirms.

Pros

  • Small writes with PATCH, scoped failures.
  • Simple: the screen never shows unconfirmed data.

Cons

  • Every save feels slow on a 600 ms round trip.
  • The member stares at a spinner for a change that succeeds 99.9% of the time.

Per-item forms, optimistic save with scoped rollback

Validate on the client first with the same schema the server uses.

On save, close the form and show the new value at once with a quiet "Saving" marker.

If the server rejects it, restore the old value in that item only and reopen the form with the draft and the server's errors.

Pros

  • Instant feedback for the common case.
  • A failure touches one item; other sections and other open forms are untouched.
  • Drafts are never lost: a failure reopens them.

Cons

  • Rollback and reopening need care.
  • Some edits (like visibility changes) should not be optimistic.

What we pick: Per-item forms with optimistic save and scoped rollback, for content edits. Client validation catches most errors before the request, so failures are rare and mostly network or conflict errors. Privacy and visibility changes are the exception: they wait for the server, because showing "only connections can see this" before it is true is a promise you might break.

State machine for editing one profile item. Viewing goes to Editing on a pencil click; Editing goes back to Viewing on cancel or Escape. Saving an invalid draft goes to Invalid, which shows errors on the fields, and fixing goes back to Editing. Saving a valid draft goes to Saving, where the new value is shown at once. On 200 the item keeps the server's copy and returns to Viewing. On 422, 412, or a network error it goes to Rolled back, which restores the old value and reopens Editing with the draft.ViewingEditingdraft in formInvalidfield errorsSavingnew value shownRolled backold value backpencilcancel, Escsave, invalidfixvalid200: server copyerrorreopen with draft
Every arrow touches one item. The only way out of Rolled back is back into Editing with the draft, so a failed save never loses what the member typed.

Validation: the same rules on both sides

Each item type has a schema shared by client and server: a position title is required and has a length limit, an end date is after the start date unless "I currently work here" is checked, the headline is at most 220 characters, About at most 2,600. The client runs it on blur and on save, so errors show next to the field before any request.

The server runs it again, because the client can be bypassed. It also checks things the client cannot know: a company id that no longer exists, a skill that is already on the profile but not in the summary the client holds, text its content filter rejects. Server errors come back keyed by field name so the form can place each one next to its input.

ts
async function saveItem(sectionId: string, draft: Item) {
  const before = store.getItem(sectionId, draft.id);     // remember only this item
  const version = store.getSectionVersion(sectionId);
  store.putItem(sectionId, { ...draft, pending: true }); // show it at once
  editor.close(sectionId, draft.id);
  try {
    const res = await api.patch(`/profile/sections/${sectionId}/items/${draft.id}`, draft, {
      headers: { "If-Match": `"${version}"` },
    });
    store.putItem(sectionId, res.item);                   // server's normalized copy
    store.setSectionVersion(sectionId, res.version);
  } catch (err) {
    store.putItem(sectionId, before);                     // undo this item only
    const errors = err.status === 422 ? err.body.fieldErrors : undefined;
    const conflict = err.status === 412;
    editor.reopen(sectionId, draft.id, { draft, errors, conflict });
    announce(conflict ? "This changed elsewhere. Review and save again." : "Could not save. Your changes are still in the form.");
  }
}
Optimistic save of one item, with rollback scoped to that item.

Note what is scoped. before is one item, not the whole profile, so if Ana saved a skill and a position at the same moment and only the position failed, the skill stays saved. The version is per section and sent as If-Match (the version works as the section's ETag): if another device changed that section since this tab loaded it, the server answers 412 Precondition Failed, and the form reopens with a note instead of overwriting the other change. Two tabs editing different sections never conflict at all.

Sequence of saving position 2. Ana presses Save in the editor. The editor puts the item in the store as pending, and the store re-renders the new title for Ana at once. The editor sends PATCH with If-Match 14 to the profile BFF. On success the BFF replies 200 with version 15 and the store keeps the server's copy. On failure the BFF replies 422 with field errors; the editor restores only that item in the store and reopens the form with the draft and the errors for Ana.AnaEditorStoreProfile BFFSave position 2put item, pendingnew title shownPATCH, If-Match200, v15or, on failure422 fieldErrorsrestore item onlyreopen with draft
The store change and the re-render happen before the network call. On failure, only the one item is restored: the headline Ana saved a second earlier stays saved.

Try it below. Pick the reply the server will send, save one or more sections, then deliver the replies: a failure undoes only its own section, and a 412 also bumps the version, as if another device had saved first.

Next reply
Three profile sections. Headline: shows Staff Engineer at Acme, status idle, version 3. Position 2: shows Senior Eng, 2019 to 2021, status idle, version 14. Skills: shows React, TypeScript, status idle, version 8.sectionon screenstateHeadlineStaff Engineer at Acmeidlev3, saved: Staff Engineer at AcmePosition 2Senior Eng, 2019 to 2021idlev14, saved: Senior Eng, 2019 to 2021SkillsReact, TypeScriptidlev8, saved: React, TypeScript
Pick a server reply, then save one or more sections. Replies arrive when you press Deliver replies.

Reordering items

Positions and education sort by date, but Featured items, Skills, and Languages have an order the owner chooses. Drag and drop is the obvious interface, and it must not be the only one: each item also gets "Move up" and "Move down" actions reachable by keyboard, and a live region announces "Moved React to position 2 of 12".

The save sends the full new order of ids for that section, not a series of moves, so a retry is idempotent (safe to send twice). The order is applied optimistically and, if the save fails, the whole section snaps back to the previous order, because a half-applied order is worse than either.

Open to work and other visibility settings

"Open to work" shows the two sides of a setting in one feature. The member picks job titles, locations, and start date (content), and an audience: all LinkedIn members (which adds the green #OpenToWork photo frame) or recruiters only, meaning people using LinkedIn's paid Recruiter tool. The content part saves like any item. The audience part is a privacy change, so it is pessimistic: the toggle shows a spinner and the new state only after the server confirms, and the guest page is purged before the change is reported done.

The frame itself is not a client decision either. The server adds an openToWork badge to the projection only for viewers inside the chosen audience, and the photo URL in the guest view is the plain photo. LinkedIn's help pages say the recruiters-only option tries to hide the signal from recruiters at your own company, but cannot guarantee it; the UI should say so in plain words next to the choice, because an honest warning is part of a privacy feature.

Check your understanding · Exercise 5

Ana edits her headline and, before that save returns, edits position 2. The headline save succeeds, the position save fails with a network error. What should she see? Select all that apply.

Connections and invitations: a small state machine shown everywhere

LinkedIn has two relationships. Connection is two-way and needs an invitation that the other person accepts. Follow is one-way and instant: you see someone's posts without them approving, and a creator can even make Follow the main button on their profile. Between two members, the connection side is always in one of four states (none, invitation sent, invitation received, or connected), and the button comes straight from it: Connect, Pending, Accept and Ignore, or Message.

The tricky part is that the same person shows up in many places at once: their profile, the search results, "People you may know" (PYMK), a mutual connections list, a comment under a post. If Ana clicks Connect on Bob in PYMK, the button on Bob's profile in another panel and his row in search must all say Pending. So the relationship lives once in the client store, keyed by member id, and every button reads it from there (the Normalizing state lesson covers the store shape).

Decision How should Connect, Accept, Ignore, and Follow update the UI?

Wait for the server

Disable the button and show a spinner until the response.

Update the store from the response.

Pros

  • The screen never shows a state the server rejected.

Cons

  • On a slow network each click takes a second or more.
  • PYMK, where people click Connect on a dozen cards in a row, feels sluggish.

Optimistic, with rollback and known limits

Update the relationship in the store at once and send the request with an idempotency key.

If the server refuses (limit reached, the other person blocked you, network), roll back that relationship and explain.

The server includes the remaining weekly invitation allowance, so the client can warn before the limit, not after.

Pros

  • Instant feedback in every place the person appears.
  • Rollbacks are rare and cheap: one key in the store.

Cons

  • A brief wrong state when a request fails.
  • Messages for each refusal reason must be written and translated.

What we pick: Optimistic, for all four actions. They are small, reversible, and succeed almost always. The idempotency key (a client-made id for the invitation) means a double tap or a retry after a timeout never sends two invitations. The exception is a server-side refusal the client can predict, like the weekly invitation limit: in this design the server sends how many invitations are left, so the UI can warn ("You can send 3 more invitations this week") before the member hits the wall.

Sequence of sending an invitation. Ana taps Connect and adds a note. The button handler sets Bob's relationship to sent in the store, and every Connect button for Bob re-renders as Pending. The handler posts the invitation to the BFF with an idempotency key. On success the BFF replies 201 with 41 invitations left this week. On failure it replies 429 weekly limit; the handler sets the relationship back to none and shows Ana a message with the reset date.AnaHandlerStoreBFFConnect + noteBob: sentPending everywherePOST, Idem-Key201, 41 leftor, limit reached429 weekly limitBob: noneexplain limit
The relationship is one entry in the store, so the profile, the search row, and the PYMK card all flip together and roll back together. The idempotency key makes a retry after a timeout safe.

Sending an invitation with a note

Clicking Connect opens a small dialog: "Add a note" or "Send without a note". The note has a character limit with a live counter, and a polite live region reads the count only near the limit, not on every keystroke.

Some invitations need more. LinkedIn sometimes asks for the other person's email address before sending, for example when many earlier recipients said they don't know the sender. That requirement comes from the server in the view (a connectNeedsEmail flag next to the connect action), so the dialog shows the extra field without guessing.

LinkedIn limits how many invitations a member can send in a week, to fight spam. It does not publish the number; members commonly report a limit of around 100 a week, and it varies by account. When the server refuses with 429 and a code like INVITE_WEEKLY_LIMIT, the optimistic Pending rolls back to Connect, and a message explains the limit and when it resets. A generic "Something went wrong" here would make members retry and hit the limit again.

Accepting and ignoring

The invitation manager lists received invitations with Accept and Ignore. Both remove the card at once. Accept also changes the relationship to connected everywhere (Bob's button becomes Message), raises the connection count, and lowers the invitations badge in the top bar.

Ignore shows an Undo in a toast for a few seconds before the request is sent, because an accidental ignore is easy and the other person gets no signal either way. Focus moves to the next card after a card is removed, so a keyboard user can work down the list without losing their place (see the Focus management lesson).

"People you may know" is a grid of cards from a server-ranked list, paged with a cursor. Dismissing a card (the X) is optimistic and the grid back-fills from the next page already in memory, so the grid never shrinks. Cards for people the member has just invited are kept but show Pending, rather than vanishing, so the member can see what they did.

Check your understanding · Exercise 6

Ana taps Connect on Bob in PYMK twice quickly on a flaky network. The first request times out on the client but actually reached the server. What keeps this correct? Select all that apply.

The feed: only what LinkedIn adds

The home feed works like the one in the News feed guide: the server ranks, the client pages with a cursor, keeps a window of rendered posts, preloads media near the viewport, and shows a "New posts" pill instead of pushing content under the reader's thumb. We won't repeat that. Three things are different enough on LinkedIn to discuss: the kinds of posts, the reactions, and reshares.

Post types and what each one costs the client.
Post typeWhat it showsClient concern
Text, image, videoUp to a few images or one videoSame as other feeds: reserved aspect ratio, lazy media, autoplay muted only when visible.
Article linkA preview card with title, image, siteThe preview is built on the server when the post is created, never by fetching the link from the viewer's browser.
DocumentA PDF shown as swipeable pagesThe server converts pages to images. The client loads the first page eagerly and the next one or two only as the reader swipes.
PollA question with 2 to 4 options and a deadlineVote optimistically, then show results. The Poll widget guide covers the details.
Long-form articleA full article page with its own URLA public, indexable page rendered like a guest profile: server HTML, edge cached, JSON-LD.

Reactions with several types

LinkedIn has six reactions: Like, Celebrate, Support, Love, Insightful, and Funny. A member gives at most one reaction per post, and can change it. So per post the client stores the viewer's current reaction (or none), a total count, and the top three reaction types to show as icons. Changing from Like to Insightful is a replace, not an add: the total stays the same and only the icon set may change, while a naive "add one on every tap" double-counts.

The picker opens on hover with a short delay on desktop and on long-press on touch. It must also open from the keyboard: the Like button is a real button, and a second control (or a keyboard shortcut on focus) opens the picker as a small menu of six buttons with labels like "React with Insightful". A quick tap on Like still gives a Like, so the common action stays one tap.

Updates are optimistic with rollback, the same pattern as Connect. The request sends the new type rather than a toggle, so retries are safe.

Reshares and the same post twice

A member can repost a post as is or "with thoughts", which wraps the original inside a new post. Two problems follow. First, the inner post is a reference, not a copy: its reaction counts and its "deleted" state come from the original post in the normalized store, so if the author deletes it, every reshare shows "This post is unavailable" at once.

Second, the same original can reach a feed several times through different connections ("Bob reposted", "Carla likes this"). The server aggregates these into one item with a social context line. The client also keeps a set of post ids already shown in this session, so a later page never shows a duplicate.

Check your understanding · Exercise 7

A post shows 120 reactions and the viewer has reacted Like. They long-press and choose Celebrate. What should the client do? Select all that apply.

The app shell: a messaging overlay and live badges on every page

On desktop, LinkedIn keeps a messaging panel docked at the bottom right of every page. A member opens a conversation with Bob, then clicks a job link, then a profile, then the feed, and Bob's chat window stays open with its draft. The top bar shows badges for My Network (invitations), Messaging, and Notifications that change while the member reads. This is the app shell: the parts of the UI that live above the routed page and survive navigation.

With classic full page loads, every click tears down the document: the open chat closes, its draft is lost, and the live connection drops and reconnects. That is why the member experience is a single-page app after the first load, while the guest pages stay plain documents. The messaging internals (ordering, delivery states, the outbox, history paging) are the same as in the Chat app and Slack guides; this chapter is about how chat and badges fit around everything else.

Decision How should the messaging overlay survive navigation between pages?

Full page loads, restore the overlay from storage

Each page is a separate document.

Save open conversations and drafts to sessionStorage and reopen them after each load.

Pros

  • Pages stay simple and independent.
  • Works with any rendering approach.

Cons

  • The overlay flashes closed and reopens on every click.
  • The live connection reconnects on every page, wasting a second of radio time on mobile.
  • Scroll position and focus inside the chat are lost.

Single-page app with a persistent shell

The root layout renders the top bar and the overlay outside the route outlet.

Navigation swaps only the outlet's content.

The live connection and the stores live at the root, for the whole session.

Pros

  • The overlay, its drafts, and its focus survive every navigation.
  • One connection for the whole visit.
  • Data already loaded (people, conversations) is reused across pages.

Cons

  • A long-lived page must watch memory.
  • The first load must hydrate the shell, which costs JavaScript.
  • Needs route-level code splitting to keep the shell small.

What we pick: A persistent shell for signed-in members. The first page arrives server-rendered and streamed, then hydrates into the app; later navigations fetch JSON and replace only the outlet. Each route (feed, jobs, profile, messaging page) is its own code-split chunk, and the overlay's conversation view is loaded on first open, so the shell itself stays small. On phones, where there is no room for a docked panel, messaging is a full route instead and the overlay is not rendered at all.

Two screens of the member app side by side. On the left, /in/ana-ruiz: a top bar with badges, the profile route in the outlet, and a chat window with Bob docked bottom right. On the right, after clicking a job, /jobs/view/42: the same top bar and the same chat window with Bob and his draft, while only the outlet now shows the job route. Below, an arrow says client-side navigation: only the outlet changes./in/ana-ruiztop bar, badgesprofile routechat: Bob/jobs/view/42top bar, badgesjob routechat: Bobclient-side navigation: only the outlet changes
The top bar and the messaging overlay are rendered by the root layout, outside the route outlet. Navigating swaps the outlet; the chat window, its draft, and its focus are never torn down.

The overlay itself

The overlay has a minimized bar ("Messaging" with your photo and an unread count), an expanded conversation list, and up to two or three open conversation windows side by side, depending on screen width. When a new window would not fit, the oldest one collapses to a small bubble. The set of open windows and their drafts is mirrored to sessionStorage, so a real reload (or a deploy that forces one) brings them back.

It is a non-modal dialog: the page behind it stays usable, so it must not trap focus or hide the rest of the page from screen readers. The Search, languages, and accessibility chapter covers its focus rules.

Live badges with one connection per browser

Badges need server push. LinkedIn's engineering blog has described serving instant messaging over Server-Sent Events (SSE), with each server holding hundreds of thousands of persistent connections; a WebSocket works too. Either way the client rules are the same, and the Choosing a real-time technique lesson covers the trade-offs. What matters here is what the events carry and how many connections a member opens.

  • Absolute counts, not deltas. A badge event says { "messaging": 3, "notifications": 12, "invitations": 1 }, not "plus one". If an event is lost during a reconnect, the next one heals the count. After any reconnect, the client also refetches the counts once.
  • One connection per browser, not per tab. Members often keep several LinkedIn tabs open. One tab holds the connection (chosen with the Web Locks API: the tab that gets the lock is the leader) and shares events with the others over a BroadcastChannel. When the leader tab closes, its lock is released and another tab takes over.
  • Seen is not read. Opening the notifications page marks the badge "seen" (the number goes to zero) without marking each notification read. Messages are read per conversation. These are separate server calls, and each tab updates from the broadcast.
  • The tab title too. A count in the title, like "(3) Feed | LinkedIn", is how people notice activity in a background tab. It is set from the same store.
How badge counts reach every tab. The push gateway sends badge totals over SSE or a WebSocket to the leader tab, the one tab holding a Web Lock. The leader posts each event to a BroadcastChannel, which delivers it to tab 2 and tab 3; each tab updates its badge store, top bar, and title. Notes: counts are totals, not plus one; after a reconnect, refetch the counts; when the leader closes, the lock passes to another tab.Push gatewaySSE or WebSocketLeader tabholds Web LocktotalsBroadcastBroadcastChannelpostMessageTab 2top bar, titleTab 3waits for lockcounts are totals, not +1after a reconnect: refetch countsleader closes: lock passes on
One connection per browser, however many tabs. Because each event carries totals, a lost or repeated event cannot leave a badge wrong for long.

Check your understanding · Exercise 8

A member has 4 LinkedIn tabs open. Which design choices are sound? Select all that apply.

Jobs: search in the URL, Easy Apply as a short multi-step form

The jobs area has three parts: search with filters, saved jobs, and Easy Apply. Job detail pages (/jobs/view/123) are like public profiles: guests arrive from Google, so they are server-rendered, edge-cached, and carry structured data (see the Search, languages, and accessibility chapter). The search page and Easy Apply are member features inside the app shell.

On desktop the search page has two panes: a results list on the left and the selected job's details on the right. On a phone the list and the details are separate screens. Filters sit in a row of chips: date posted, experience level, remote, hybrid, or on-site, company, salary, and "Easy Apply only".

The whole search lives in the URL: keywords, location, every filter, the page cursor, and the selected job id. That makes a search shareable, lets the back button undo a filter, and lets a reload land on the same job (see the URL as state lesson).

  • Keywords run on submit, filters run on change. Typing does not search on each key (autocomplete suggestions do, debounced). Toggling a chip searches at once.
  • Abort stale requests. Each new search aborts the previous one with an AbortController, so a slow response for "Remote" can never overwrite the newer "Remote, Senior" results (see the Request races and cancellation lesson).
  • Selecting a job replaces the URL entry, not pushes, so clicking through ten jobs does not need ten presses of Back to leave the page. Changing a filter pushes a new entry.
  • Prefetch the next details. When the list renders, prefetch the details of the first job, and of any job the pointer rests on for 150 ms, so the right pane fills without a spinner.

Saving a job is a bookmark toggle: optimistic, keyed by job id in the store so the icon agrees in the list, the details pane, and the "My jobs" page, with rollback on failure. Its state also arrives in the search response, so the icons are right on first render.

Easy Apply

Easy Apply is a short application inside LinkedIn: contact details filled from the profile, a resume, a few questions the employer wrote ("How many years of React?", "Do you need sponsorship?"), a review, and submit. It opens as a modal dialog, unlike the chat overlay: the member is doing one task, the page behind is inert, and focus is trapped inside until they finish or close it.

The multi-step mechanics (a step machine, per-step validation, Back that keeps answers) are the same as in the Travel booking guide. Here is what is specific to job applications.

Decision Where should an in-progress application live?

Only in component state

Answers live in the dialog's state until Submit.

Pros

  • Nothing to store on the server.
  • Simplest to build.

Cons

  • Closing the dialog, a reload, or a crash loses everything.
  • The member can't start on the phone and finish on a laptop.

A server-side draft saved at each step

Pressing Next saves that step's answers to a draft keyed by member and job.

Reopening Easy Apply loads the draft and resumes at the first unfinished step.

Submit turns the draft into an application with an idempotency key.

Pros

  • Nothing is lost on close, reload, or device switch.
  • Each save is small and validated by the server at that step.

Cons

  • Drafts need cleanup when jobs close.
  • One request per step on a slow network.

What we pick: A server-side draft saved at each step. An application takes several minutes, and the cost of losing it (a member gives up on a job) is far higher than one small request per step. Closing the dialog asks "Save this application?" with Save and Discard, and Save is the default.

Easy Apply as a state machine. Contact goes to Resume on Next, saving the step to the server draft. Resume goes to Questions on Next. Choosing a new file in Resume starts Uploading with a progress bar, which runs in parallel. Questions goes to Review on Next. Review goes to Applied on Submit with an idempotency key, once the upload is done. A network error on submit goes to Error, which retries with the same key back through Review. Closing at any step offers Save draft, the default, or Discard.Contactfrom profileResumepick or uploadQuestionsfrom a schemaNextNextUploadingprogress barnew fileReviewall answersNextAppliedconfirmationSubmitErrorretry, same keyfailclose at any step:Save draft (default) or Discard
Every Next saves the step, so closing the dialog or switching devices loses nothing. The upload runs beside the steps, and only Submit waits for it.

The resume step offers the resumes the member uploaded before, or a new upload (PDF or Word, with a size limit of a few MB). The upload starts as soon as the file is chosen and shows progress, while the member can already read the next step; Submit waits for it to finish.

Resumes are small, so a single request is enough; the File uploader guide covers chunked and resumable uploads for big files. The client checks type and size before sending, and the server checks them again and scans the file.

Employer questions come as a schema: each question has a type (number, yes or no, single choice, text), a label, and rules. The client renders the form from that schema, so a new question type is a new renderer, not a new release of every form. Answers to common questions are offered again on the next application, which is most of what makes the next Easy Apply fast.

Check your understanding · Exercise 9

Which choices make job search and Easy Apply hold up on a slow, flaky network? Select all that apply.

Architecture, data model, and API

With the big decisions made, the pieces fall into three layers. At the edge, a router splits guests from members and a CDN caches guest pages. On the server, two renderers share one component library: a guest renderer for documents and a member renderer for the app's first load, both fed by a BFF that applies the privacy projection. In the browser, members run an app shell with stores, a live connection, and route chunks, while guests run only a few islands.

Who owns what

Component architecture. In the browser: the app shell with the top bar and overlay, code-split routes, the entity store of people and jobs, the query cache, section editors loaded only for the owner, the API client with fetch and abort, a session store for drafts and the overlay, and the realtime client in the leader tab. On the server side: the edge and CDN route by cookie and cache guest pages, then the guest renderer with islands and the member renderer that streams, both reading from the Profile BFF, which applies the projection; and the push gateway over SSE or WebSocket. The edge sends HTML to the routes, the API client sends JSON to the BFF, and the realtime client receives events from the push gateway.BrowserApp shelltop bar, overlayRoutescode-splitEntity storepeople, jobsQuery cacheper queryEditorsowner onlyAPI clientfetch, abortSession storedrafts, overlayRealtimeleader tabEdge and serverEdge, CDNroute by cookie, cache guestsGuest SSRislandsMember SSRstreamedProfile BFFprojection, actionsPush gatewaySSE or WebSocketHTMLJSONpush
The BFF is the only door to profile data, for both renderers and for the client. The realtime client and the API client are the only parts of the browser that talk to the network.
ComponentResponsibilityWhy the boundary is here
Edge router and CDNRoute by session cookie; cache guest HTML per profile and language; purge by tag.The only place that sees every request before the origin, so caching and routing are cheapest here.
Guest rendererServer-render public profiles, job pages, articles from the guest projection; hydrate islands only.Its output is shared by everyone, so it must never see member data at all.
Member rendererStream the first page for one viewer, then hand over to the client app.Personal output, no-store. Same components as the guest renderer, different data and bundle.
Profile BFFLoad profile, relationship, settings; project fields; compute actions.One place for privacy rules, used by both renderers and the JSON API.
App shell (client)Top bar, router outlet, messaging overlay, toasts.Lives for the whole visit so chat and badges survive navigation.
Entity store (client)People, relationships, posts, jobs, conversations, normalized by id.The same person or job appears in many places; one copy keeps them in agreement.
Query cache (client)Server responses per query key, with staleness and refetch rules.Lists and pages come and go; the store holds entities, the cache holds which ids a query returned.
Realtime clientOne SSE or WebSocket connection in the leader tab; broadcasts to other tabs.One connection per browser, not per tab or per feature.
Section editorsPer-item forms, validation, optimistic saves, rollback.Code-split and loaded only for the owner, so viewers never download them.

The data flow for the main screen follows those boundaries. A guest request stops at the CDN most of the time. A member's first request goes through the member renderer, which asks the BFF for the projected view and streams HTML with the view embedded for hydration.

After that, the client asks the BFF directly for JSON when navigating, writes edits through the same BFF, and receives live counts over the push connection. Edits flow back as purge events to the CDN, so the guest copy catches up.

Data flow for the profile page. Profiles and graph data go into the Profile BFF, which builds the ProfileView. The member SSR streams HTML with the view embedded to the browser's components, which hydrate and fill the query cache. Later reads and writes go from the query cache through the edge as JSON and PATCH requests to the BFF. The BFF purges the edge cache by tag after edits. The push gateway sends badge totals and relationship events to the entity store, and components re-render from the store and cache.Profiles, graphraw dataProfile BFFprojectionPush gatewaybadge totalsEdge, CDNpurge by tagMember SSRstreams HTMLProfileViewJSON, PATCHBrowserQuery cacheviews, pagesComponentshydratedEntity storepeople, relationsHTML + vieweventson edit: BFF writes, then purges the guest copy at the edge
Reads come down as projected views, writes go up as small scoped calls, and live events update the store directly. After an edit the BFF purges the guest copy at the edge.

Check your understanding · Exercise 10

Why does the guest renderer never receive member data, even though the member renderer uses the same components? Select all that apply.

Data model: what the client holds and where

On the client, a profile is a ProfileView (the projection from the privacy chapter), with sections as summaries and a version per section for conditional saves. Members, relationships, posts, and jobs are entities in a normalized store, because they appear on many screens at once. Everything else lives where its lifetime fits.

ts
type Degree = "self" | "first" | "second" | "third" | "member"; // "member": signed in, no path
type Restricted = { restricted: "members" | "network" | "connections" };

interface ProfileView {
  memberId: string;
  vanity: string;                       // "ana-ruiz", used for the canonical URL
  topCard: { name: string; headline: string; photoUrl: string | null; location?: string; openToWork?: boolean };
  degree?: Degree;                      // missing for guests
  mutual?: { count: number; sample: MemberRef[] };
  email?: string | Restricted;
  connectionCount?: number | "500+";
  sections: SectionSummary[];           // server order; unknown types are skipped
  actions: Action[];                    // what this viewer may do
  connectNeedsEmail?: boolean;          // Connect must ask for the email address
  indexable: boolean;                   // drives robots meta
}

interface SectionSummary { type: string; version: number; total: number; items: Item[]; nextCursor?: string }
type Action = "edit" | "connect" | "withdraw" | "accept" | "message" | "follow" | "unfollow" | "signIn";
type Relationship = { memberId: string; connection: "none" | "sent" | "received" | "connected"; following: boolean };
Client-side types. Fields marked optional can be missing for a given viewer.
DataWhere it livesWhy
ProfileView, job details, search resultsQuery cache, keyed by URL or queryServer state with a lifetime; refetched when stale or after a mutation.
Members, relationships, posts, jobsNormalized entity storeOne copy per id so every button and badge agrees.
Search filters, selected job, section page cursorURLShareable, survives reload, back button undoes it.
Open edit forms and draftsComponent state, mirrored to sessionStorageLocal to one tab; restored after an accidental reload.
Overlay layout and chat draftsShell state, mirrored to sessionStorageMust survive navigation and reloads in this tab only.
Badge countsShell store, fed by the push connectionGlobal, live, shared across tabs by broadcast.
Easy Apply draftServer, per member and jobMust survive closing, reloads, and switching devices.
SessionHttpOnly, Secure cookieNever readable by scripts; the edge only checks that it exists.

Two rules keep this model safe. On sign-out, the query cache, the entity store, and every sessionStorage key are cleared, and other tabs are told over the broadcast channel to do the same. And nothing from a ProfileView is written to localStorage or IndexedDB: a professional profile seen under one relationship must not outlive that relationship on a shared or lost device.

Where client data lives, in three columns. The query cache holds ProfileView per viewer, section pages by cursor, and search results as ids only. The entity store holds Member with name and photo, Relationship with sent or connected, Job with saved, and Post with the viewer's reaction. Elsewhere: the URL holds filters and the selected job, sessionStorage holds drafts and the overlay layout, and the server holds the Easy Apply draft. Search results point to jobs by id, and ProfileView points to relationships by id.Query cacheProfileViewper viewerSection pagesby cursorJob searchids onlyEntity storeRelationshipsent, connectedMembername, photoJobsaved: truePostmy reactionElsewhereURLfilters, job idsessionStoragedrafts, overlayServer draftEasy ApplyCookieHttpOnly session
Lists in the query cache hold ids; the entities they point to live once in the store. Nothing personal goes to long-lived storage on the device.

Check your understanding · Exercise 11

Bob is shown in PYMK, in a search result, and on his own profile page, all in the same session. Ana connects with him from PYMK. Which data model choices make all three update? Select all that apply.

Interface: the BFF API, push events, and component contracts

The client talks to one BFF over JSON. Reads return projected views; writes are small PATCH or POST calls on one item or one relationship, with versions for conflict checks and idempotency keys for actions that must not repeat. GraphQL would also fit (one query per screen, typed fields), and LinkedIn's own backend services use Rest.li, its open-source REST framework. The contract matters more than the protocol: every read is already projected, and every write is scoped.

http
GET   /api/profiles/ana-ruiz                 -> ProfileView
GET   /api/profiles/ana-ruiz/sections/experience?cursor=c2&limit=20
      -> { items: Item[], nextCursor?: string, version: 14 }

PATCH /api/profile/sections/experience/items/pos_31
      If-Match: "14"
      { "title": "Staff Engineer", "start": "2021-03", "current": true }
      200 -> { item: Item, version: 15 }
      412 -> { error: "VERSION_CONFLICT", current: 16 }
      422 -> { error: "VALIDATION", fieldErrors: { "end": "End date is before start date" } }

PUT   /api/profile/sections/skills/order      If-Match: "8"
      { "ids": ["sk_4", "sk_1", "sk_9"] }

POST  /api/invitations                        Idempotency-Key: 6f1c...
      { "to": "mem_812", "note": "We met at JSConf" }
      201 -> { relationship: Relationship, invitesLeftThisWeek: 41 }
      429 -> { error: "INVITE_WEEKLY_LIMIT", resetsAt: "2026-10-26T00:00:00Z" }

POST  /api/invitations/inv_77/accept           -> { relationship: Relationship }
PUT   /api/posts/p_19/reaction   { "type": "INSIGHTFUL" }
POST  /api/jobs/j_42/application-draft/steps/resume   { "resumeId": "rs_3" }
POST  /api/jobs/j_42/applications             Idempotency-Key: a90e...
Reads and writes. Every read is the projection for the signed-in viewer.

Errors share one shape: a stable error code the client can switch on, a human message for logs, and optional fieldErrors keyed by field name. The client maps codes to translated messages; it never shows the server's English text to a member reading in Arabic. Lists page with opaque cursors, never offsets, because profiles and feeds change while someone scrolls.

ts
type PushEvent =
  | { type: "badges"; messaging: number; notifications: number; invitations: number }
  | { type: "message"; conversationId: string; message: MessageSummary }
  | { type: "relationship"; relationship: Relationship }   // e.g. Bob accepted
  | { type: "profileVersion"; section: string; version: number }; // edited elsewhere

interface SectionEditorProps {
  sectionType: string;
  item?: Item;                       // missing when adding
  schema: FieldSchema[];             // shared with the server
  onSave(draft: Item): Promise<SaveResult>;
  onCancel(): void;
  returnFocusTo: HTMLElement | null; // the pencil button that opened it
}
Push events and the section editor's contract.

The profileVersion event lets an open profile tab notice that the owner edited a section on another device. The client then marks that section stale; if an editor for it is open, it shows "This section changed elsewhere" instead of waiting for a 412 on save.

Check your understanding · Exercise 12

Why does the skills reorder endpoint take the full list of ids with If-Match, instead of "move sk_4 up one"? Select all that apply.

Performance on mid-range phones and slow networks

LinkedIn has invested in this for years. In 2016 it launched LinkedIn Lite in India, first as a light mobile site and in 2017 as an Android app; LinkedIn said the app took about 1 MB, cut data use by 80%, and loaded a page in under five seconds even on 2G.

The lesson for the main web app is not "build a second app" but "budget the first one": decide how many bytes and how much main-thread time each page may cost, and enforce it in CI.

Example budgets, compressed sizes. The numbers are targets for this design, not LinkedIn's published figures.
PageHTMLJavaScriptLCP elementTarget
Guest profile15 to 30 KBunder 100 KB (islands only)Profile photo, about 10 to 20 KB AVIF or WebPLCP under 2.5 s at p75 on mobile
Guest job page15 to 25 KBunder 100 KBJob title textLCP under 2.0 s
Member first loadstreamedshell about 150 to 200 KB, plus the route chunkTop card or first postLCP under 2.5 s, INP under 200 ms
Member navigationnone (JSON)route chunk 30 to 80 KB, often cachedNew page contentUnder 1 s to content on 4G

Network: fewer bytes, fewer round trips

  • The LCP image first. The profile photo gets fetchpriority="high", explicit width and height, and a srcset so a phone downloads a 200 px image, not an 800 px one. Below-the-fold images use loading="lazy".
  • Compression and protocols. Brotli for text, HTTP/2 or HTTP/3 to the CDN, and preconnect to the media host so its connection is ready before the first image is requested.
  • Respect Save-Data when it is there. Some browsers send a Save-Data: on request header when the user asks to save data. Support is patchy and has shrunk, so treat it as a bonus: when it arrives, the server picks lower-quality images and the client never autoplays video in the feed.
  • Cache the shell. Hashed chunks are immutable; a member's second visit downloads only changed chunks. A service worker can precache the shell and route chunks for repeat visits (see the Service worker caching lesson), but never caches personalized HTML or JSON.

CPU: less work on the main thread

  • Hydrate less. Guest pages hydrate islands only. Member pages use server components for static sections, so only interactive parts (buttons, editors, the overlay) ship their code.
  • Split by route and by role. Editors load on first click of a pencil; viewers never download them. The messaging conversation view loads on first open of the overlay.
  • Skip off-screen rendering. Long profile sections and feed posts below the fold use content-visibility: auto with a contain-intrinsic-size, so the browser skips their layout and paint until they are close. The feed also recycles off-screen posts (see the List virtualization lesson).
  • Break up long tasks. Handlers that do real work (applying a large search response, rendering a list of 25 jobs) yield to the browser between chunks, using scheduler.yield() where supported and a setTimeout fallback, so a tap is never stuck behind a 300 ms task.

Measure in the field. Lab tests on a fast laptop say little about a phone in Jakarta. Collect Core Web Vitals from real sessions (the web-vitals library reports LCP, INP, and CLS), tag each with page type, device memory bucket, effective connection type, and country, and watch the 75th percentile per segment. A regression that only shows up for 2 GB phones in one region is exactly the kind a global average hides.

Check your understanding · Exercise 13

The guest profile's LCP is 4.1 s at p75 on mobile. The HTML arrives in 400 ms. Which changes are most likely to help? Select all that apply.

Search, languages, and accessibility

Public profiles and job posts are landing pages: for many people, the first LinkedIn page they ever see is a search result. So search engines need clean signals, the page must read correctly in the visitor's language and script, and every form and panel must work without a mouse. These three topics share one theme: what the server puts in the HTML decides most of the outcome.

SEO for profiles and jobs

  • One canonical URL. The profile is reachable with tracking parameters, from country subdomains, and with or without a trailing slash. Every variant declares rel="canonical" pointing at https://www.linkedin.com/in/ana-ruiz so ranking signals collect on one URL.
  • Vanity slug changes. When Ana changes her slug, the old one is reserved for her for a while so nobody else can take it. In this design it also returns a 301 to the new slug during that time, then is released. The canonical always uses the current slug. Internal links use the member id and resolve to the slug at render time, so they never break.
  • Structured data from the guest projection. A JSON-LD block of type ProfilePage whose mainEntity is a Person, with only public fields: name, headline as jobTitle, photo, current company, schools. Google documents ProfilePage markup for user profiles on social and community sites: mainEntity is required and the Person needs a name. Fields like jobTitle, worksFor, and alumniOf are valid schema.org properties that Google does not list, so treat them as extra context, not a ranking lever.
  • Job posts carry JobPosting JSON-LD with the fields Google requires (title, description, datePosted, hiringOrganization, and jobLocation unless the job is fully remote), plus validThrough, which Google requires when a posting has an expiry date. Remote jobs use jobLocationType: "TELECOMMUTE" with applicantLocationRequirements naming at least one country. When a job closes, validThrough moves to the past, the markup is removed, or the page returns 404 or 410; Google recommends its Indexing API to report the change quickly.
  • Hidden profiles stay out. If a member turns off public visibility, the guest response is a short sign-in page with X-Robots-Tag: noindex, the cache is purged, and the URL is removed from the sitemap. Search engines drop it when they next crawl it, which can take weeks. Until then their snippet is out of our hands, which is why the purge happens before Ana is told the change is done.
  • Titles and previews. title, meta description, and Open Graph tags (og:title, og:image) come from the guest projection, so a link pasted into a chat shows the public photo, never a restricted one.
json
{
  "@context": "https://schema.org",
  "@type": "ProfilePage",
  "dateModified": "2026-10-02T09:14:00Z",
  "mainEntity": {
    "@type": "Person",
    "name": "Ana Ruiz",
    "jobTitle": "Staff Engineer",
    "image": "https://media.example.com/p/8812-200.webp",
    "worksFor": { "@type": "Organization", "name": "Acme" },
    "alumniOf": { "@type": "CollegeOrUniversity", "name": "Universidad de Sevilla" },
    "url": "https://www.linkedin.com/in/ana-ruiz"
  }
}
The guest profile's JSON-LD, built only from public fields.

Languages and right-to-left

There are two languages in play. The UI language is the viewer's choice (or, for guests, picked from the URL and Accept-Language). The profile language is the owner's: LinkedIn lets members add a profile in a second language, so Ana can have Spanish and English profiles, and viewers see the one matching their LinkedIn language, falling back to the primary one.

For guests, each version is a separate cache entry (the cache key already includes the language). The page lists the others with hreflang alternate links, so search engines can show the right one for each language and region. The Translating an app and Right-to-left layouts lessons cover the general setup.

  • Formatting with Intl. "Mar 2021 to Present, 4 yrs 7 mos" is built from Intl.DateTimeFormat and translated messages with plural rules, never string concatenation. Counts like "500+ connections" use Intl.NumberFormat.
  • Right-to-left UI. For Arabic or Hebrew, the html element gets dir="rtl", CSS uses logical properties (margin-inline-start, not margin-left), the messaging overlay docks bottom-left, and direction icons like arrows flip while logos do not.
  • Mixed-direction text. An Arabic UI often shows an English headline, and the reverse. User-written fields get dir="auto", and names inside translated sentences are wrapped in bdi so "Ana Ruiz reacted" does not scramble its punctuation.
  • Translations per locale chunk. Each route loads only its own messages for the active locale, so a member reading in Hindi does not download every language.

Accessibility: forms, dialogs, and the overlay

Profile forms are long and full of rules. Every input has a visible label; errors appear under the field, are linked with aria-describedby, and set aria-invalid="true". On a failed save, focus moves to the first invalid field and a short summary is announced. Month and year pickers are two plain select elements, which every screen reader handles, instead of a custom calendar.

Where the editor is a dialog (always on phones), it opens with showModal(): focus moves to its first field, the page behind is inert, and Escape closes it. Inline or modal, closing the editor returns focus to the pencil button that opened it.

The messaging overlay is the opposite: non-modal. The page stays usable, so there is no focus trap and no aria-modal. Each conversation window is a labelled container ("Conversation with Bob Lee"): a dialog opened with show(), never showModal(), or a plain region. When the member opens a conversation, focus goes to its message box, because they asked to write.

When a message arrives in a window the member is not in, focus never moves, and a polite live region says "New message from Bob Lee" once rather than reading every message. A keyboard shortcut (like F6 in many desktop apps) moves focus between the page and the overlay. Escape minimizes the focused window and returns focus to where it came from, and the slide-in animation is skipped under prefers-reduced-motion.

Small details matter at scale. The "2nd" badge reads as "2nd degree connection" through visually hidden text, and unread badges in the top bar are part of the link's accessible name ("Messaging, 3 unread"), not a separate number read out of context. Reaction counts say what they count. And the Connect, Pending, and Message buttons keep the same position as they change, so a keyboard user's focus stays on the button they pressed.

Check your understanding · Exercise 14

Which of these are correct for the guest profile and the messaging overlay? Select all that apply.

Running it in the interview

"Design LinkedIn" is too big for 45 minutes, and the interviewer knows it. The signal they want is whether you find the part that is specific to LinkedIn and go deep there, instead of drawing a generic feed. Say early that the feed and chat follow known patterns, and that you will spend most of the time on the profile: two audiences, privacy, and editing.

MinutesWhat to doWhat to draw or say
0 to 5Scope: who visits profiles, privacy settings, editing, what to skip."Most profile views come from search, signed out. I'll treat the guest page as a document and the member page as an app."
5 to 15Rendering and caching the profile route.The edge router splitting guests and members, the cache headers for each, purging by tag.
15 to 23Privacy: the per-viewer projection.The BFF pipeline: profile plus relationship plus settings gives a ProfileView. "The client never gets a hidden value."
23 to 31Profile sections and editing.Summaries plus deferred sections; the per-item save with optimistic update, If-Match, and scoped rollback.
31 to 38Shell, connections, jobs.The persistent overlay and one push connection per browser; Connect as optimistic with idempotency; Easy Apply drafts.
38 to 45Performance, SEO, accessibility, and follow-ups.Budgets, structured data from the guest projection, the non-modal overlay.

Draw the architecture early and keep pointing at it. Two boxes do most of the work: the edge router (which decides cacheable or private) and the BFF projection (which decides what each viewer gets). If you run short of time, cut jobs and the feed first; never cut privacy, because it is where a senior answer and a junior answer differ most.

Follow-up questions and strong answers

Think first

Ana makes her profile non-public. A crawler fetches it 2 seconds later. What does it get?

Think first

How would you build "View as public" so Ana can preview what guests see?

Think first

Scrapers read public profiles at scale. What can the frontend do about it?

Check your understanding · Exercise 15

You have 10 minutes left and have covered rendering and privacy. Which next topics give the strongest signal for this problem? Select all that apply.

Summary

  • One URL, two products: guests and crawlers get a server-rendered document cached at the edge per profile and language; members get a private, streamed render that hydrates into an app. The edge routes on the mere presence of a session cookie.
  • The server decides, the client displays: a BFF projects each profile for one viewer from the relationship and the owner's settings. Hidden values never reach the browser, not in HTML, JSON, hydration data, JSON-LD, or image URLs.
  • Degree badges, mutual connections, and allowed actions come from the server in the view. The client never derives them, because it would need data it must not have.
  • The profile page shows summaries and server-renders the text sections that matter for search; heavy and personal sections load near the viewport, and editors load only for the owner.
  • Edit one item at a time: client and server share a schema, saves are optimistic with If-Match versions per section, and a failure rolls back only that item and reopens its draft. Privacy changes wait for the server.
  • Relationships live once per member id in the store, so Connect, Pending, and Message agree everywhere; actions are optimistic with idempotency keys, and known limits are shown before they bite.
  • Members get a persistent shell: the messaging overlay is a non-modal panel outside the route outlet, and one push connection per browser feeds absolute badge counts to every tab.
  • Budget bytes and main-thread time per page, measure in the field by device and country, and keep the guest page nearly free of JavaScript.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next