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-ruizfrom 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.
| Question | Answer we assume | What 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
noindexand 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.
- One URL, many audiences.
/in/ana-ruizis 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. - 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.
- 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.
- 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.
- 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.
| Trait | Twitter or Instagram | |
|---|---|---|
| Main entry point | The home feed, signed in | A public profile or job page, often signed out, from search |
| What decides what a viewer sees | Mostly public or private account | Per-field audiences plus graph degree plus private mode |
| Main write path | Post a tweet or photo | Edit one of many profile sections; send an invitation |
| Relationship model | One-way follow | Two-way connection with an invitation step, plus one-way follow |
| SEO weight | Some (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?
The guest profile. It is the page with the most traffic from outside, it is the one search engines judge, and it is the one where a privacy leak is worst, because a cached guest page is served to everyone.
Building it first also forces the right shape: a server-built, per-viewer view of the profile that the member version can reuse with more fields filled in.
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?
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.
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.
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;
}
| Path | Cache-Control | Why |
|---|---|---|
| Guest profile HTML | public, max-age=0, s-maxage=600 | Edge keeps it 10 minutes; edits purge it sooner by tag. |
| Member profile HTML | private, no-store | Personal. No cache, shared or in the browser, may keep it. |
| Profile hidden from guests | private, no-store plus X-Robots-Tag: noindex | The guest response is a short "sign in to view" page, never cached as the profile. |
| Hashed JS and CSS | public, max-age=31536000, immutable | File 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?
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.
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.
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 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.
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.
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?
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.
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.
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>;
});
}
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?
A URL can be shared, bookmarked, and opened in a new tab, and the back button returns to the profile at the same scroll position.
It keeps the main profile page bounded: no matter how many positions someone has, the main page holds at most a few per section. The long list gets its own paging and its own cache entry.
On a phone the full list is easier to read as its own screen than as a section that grows by 25 screens.
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?
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.
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.
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.");
}
}
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.
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.
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?
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.
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 type | What it shows | Client concern |
|---|---|---|
| Text, image, video | Up to a few images or one video | Same as other feeds: reserved aspect ratio, lazy media, autoplay muted only when visible. |
| Article link | A preview card with title, image, site | The preview is built on the server when the post is created, never by fetching the link from the viewer's browser. |
| Document | A PDF shown as swipeable pages | The server converts pages to images. The client loads the first page eagerly and the next one or two only as the reader swipes. |
| Poll | A question with 2 to 4 options and a deadline | Vote optimistically, then show results. The Poll widget guide covers the details. |
| Long-form article | A full article page with its own URL | A 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?
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.
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.
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.
Search with filters
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?
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.
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 | Responsibility | Why the boundary is here |
|---|---|---|
| Edge router and CDN | Route 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 renderer | Server-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 renderer | Stream 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 BFF | Load 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 client | One SSE or WebSocket connection in the leader tab; broadcasts to other tabs. | One connection per browser, not per tab or per feature. |
| Section editors | Per-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.
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.
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 };
| Data | Where it lives | Why |
|---|---|---|
| ProfileView, job details, search results | Query cache, keyed by URL or query | Server state with a lifetime; refetched when stale or after a mutation. |
| Members, relationships, posts, jobs | Normalized entity store | One copy per id so every button and badge agrees. |
| Search filters, selected job, section page cursor | URL | Shareable, survives reload, back button undoes it. |
| Open edit forms and drafts | Component state, mirrored to sessionStorage | Local to one tab; restored after an accidental reload. |
| Overlay layout and chat drafts | Shell state, mirrored to sessionStorage | Must survive navigation and reloads in this tab only. |
| Badge counts | Shell store, fed by the push connection | Global, live, shared across tabs by broadcast. |
| Easy Apply draft | Server, per member and job | Must survive closing, reloads, and switching devices. |
| Session | HttpOnly, Secure cookie | Never 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.
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.
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...
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.
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
}
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.
| Page | HTML | JavaScript | LCP element | Target |
|---|---|---|---|---|
| Guest profile | 15 to 30 KB | under 100 KB (islands only) | Profile photo, about 10 to 20 KB AVIF or WebP | LCP under 2.5 s at p75 on mobile |
| Guest job page | 15 to 25 KB | under 100 KB | Job title text | LCP under 2.0 s |
| Member first load | streamed | shell about 150 to 200 KB, plus the route chunk | Top card or first post | LCP under 2.5 s, INP under 200 ms |
| Member navigation | none (JSON) | route chunk 30 to 80 KB, often cached | New page content | Under 1 s to content on 4G |
Network: fewer bytes, fewer round trips
- The LCP image first. The profile photo gets
fetchpriority="high", explicitwidthandheight, and asrcsetso a phone downloads a 200 px image, not an 800 px one. Below-the-fold images useloading="lazy". - Compression and protocols. Brotli for text, HTTP/2 or HTTP/3 to the CDN, and
preconnectto 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: onrequest 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: autowith acontain-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 asetTimeoutfallback, 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 athttps://www.linkedin.com/in/ana-ruizso 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
301to 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
ProfilePagewhosemainEntityis aPerson, with only public fields: name, headline asjobTitle, photo, current company, schools. Google documentsProfilePagemarkup for user profiles on social and community sites:mainEntityis required and thePersonneeds aname. Fields likejobTitle,worksFor, andalumniOfare valid schema.org properties that Google does not list, so treat them as extra context, not a ranking lever. - Job posts carry
JobPostingJSON-LD with the fields Google requires (title,description,datePosted,hiringOrganization, andjobLocationunless the job is fully remote), plusvalidThrough, which Google requires when a posting has an expiry date. Remote jobs usejobLocationType: "TELECOMMUTE"withapplicantLocationRequirementsnaming at least one country. When a job closes,validThroughmoves to the past, the markup is removed, or the page returns404or410; 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.
{
"@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"
}
}
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 fromIntl.DateTimeFormatand translated messages with plural rules, never string concatenation. Counts like "500+ connections" useIntl.NumberFormat. - Right-to-left UI. For Arabic or Hebrew, the
htmlelement getsdir="rtl", CSS uses logical properties (margin-inline-start, notmargin-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 inbdiso "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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope: 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 15 | Rendering and caching the profile route. | The edge router splitting guests and members, the cache headers for each, purging by tag. |
| 15 to 23 | Privacy: the per-viewer projection. | The BFF pipeline: profile plus relationship plus settings gives a ProfileView. "The client never gets a hidden value." |
| 23 to 31 | Profile sections and editing. | Summaries plus deferred sections; the per-item save with optimistic update, If-Match, and scoped rollback. |
| 31 to 38 | Shell, connections, jobs. | The persistent overlay and one push connection per browser; Connect as optimistic with idempotency; Easy Apply drafts. |
| 38 to 45 | Performance, 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?
The "hide" path purges first, then confirms: the settings service writes the change, emits an event, the purge runs across the CDN by the profile-8812 tag, and only then does Ana's toggle show the new state. A request after that misses the cache, reaches the guest renderer, which now sees a non-public profile and returns the noindex sign-in page with no-store.
If the purge failed in one region, the short s-maxage bounds the damage to minutes, and a retry queue keeps purging. For making a profile public again there is no hurry: the old cached sign-in page is not stored at all.
Think first
How would you build "View as public" so Ana can preview what guests see?
Ask the BFF for the projection with the relationship forced to guest and render it with the guest components inside her app. Never build it by hiding fields from her full view on the client: that would test a different code path from the real guest page.
Better still, the preview can request the actual guest HTML for her profile, so what she sees is byte for byte what the CDN serves.
Think first
Scrapers read public profiles at scale. What can the frontend do about it?
Less than the backend, but some things. Keep the guest page minimal (only fields the owner made public, no contact details), avoid a public JSON API for guests so scrapers must parse HTML, and serve a sign-in wall from the edge to clients that read too many profiles too fast.
Verified search crawlers get higher limits, checked by reverse DNS, not by user agent, which anyone can fake. The same page goes to crawlers and guests, so this never becomes cloaking.
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-Matchversions 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.
References
- Profile page (ProfilePage) structured data Google's documentation for ProfilePage and Person markup.
- Job posting (JobPosting) structured data Required and recommended fields, remote jobs, and removing expired posts.
- How to specify a canonical URL rel=canonical, redirects, and duplicate URLs.
- Instant Messaging at LinkedIn: Scaling to Hundreds of Thousands of Persistent Connections on One Machine LinkedIn's engineering post on serving messaging over Server-Sent Events.
- LinkedIn Lite launches as an Android app in India LinkedIn's lightweight product for slow networks, with its stated size and load time.
- Largest Contentful Paint (LCP) What LCP measures and the thresholds used in this guide.
- Interaction to Next Paint (INP) Responsiveness on weak devices.
- Cache-Control public, private, no-store, s-maxage, and stale-while-revalidate.
- RFC 9213: Targeted HTTP Cache Control CDN-Cache-Control: cache rules for the edge only, without changing what browsers do.
- Understand JavaScript SEO basics How Googlebot crawls, renders, and indexes pages that depend on JavaScript.
- LinkedIn Help: profile viewing options The three browsing modes and what the viewed member sees for each.
- Web Locks API Electing one tab to hold the live connection.
- Dialog (Modal) Pattern WAI-ARIA Authoring Practices for modal dialogs such as the section editor and Easy Apply.
Concepts behind it
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Streaming server renderingThe server sends a page's HTML in pieces as each part becomes ready, instead of waiting for its slowest data: the layout paints right away, slow sections stream in later, and each piece hydrates on its own.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.