Design an embeddable poll widget
A small poll that news sites and blogs paste into their pages: a question, a few options, a vote, and live results. It looks like a form with radio buttons, but the hard parts are running safely inside someone else's page, keeping it tiny, allowing one vote per person without tracking anyone, showing results that update live and add up to 100%, and surviving a traffic spike when a big site embeds it.
The question
Our company sells a poll product. A customer, say a news site, creates a poll in our dashboard and gets a snippet to paste into an article. Readers see the question, vote, and then see the results, which update as other people vote.
Design the frontend of the widget that runs on the customer's page. How do we embed it, how do we stop people voting twice, how do results stay live, and how do we keep it from slowing down or breaking the host page?
Real-life examples: A poll embedded in a news article, Polls in X (Twitter) posts, Slido polls during a talk, Poll blocks in Substack posts, Polls in Discord and Slack messages
Requirements: what to ask before drawing anything
A poll widget is a tiny app that shows one question with two to ten answers. A reader picks an answer, presses Vote, and then sees how everyone else voted: a bar, a percentage, and a count for each answer. Embeddable means it does not live on our site. It lives inside pages we don't own, written by people we have never met.
That one word changes the whole problem. On our own site we control the CSS, the fonts, the other scripts, the security headers, and the release schedule. On a host page we control none of them.
The host may have a global rule like button { all: unset }, a strict Content Security Policy, a slow phone audience, and an SEO team that measures every millisecond of page load. Our widget must look right, work right, and stay out of the way in all of those places.
Start with questions. Each answer below changes something in the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who embeds it? | Thousands of sites: big news publishers, small blogs, community forums. Customers paste a snippet; many can't run a build step. | The embed must be one copy-paste line that works on any page, with no framework assumptions. |
| How much traffic? | Most polls get a few hundred votes. A poll in a top story on a big news site can get 2 million views and 300,000 votes in an hour, with peaks of 2,000 votes per second. | Reads and live updates must be served from a CDN or a fan-out layer, not one database row per viewer. |
| Single or multiple choice? | Both. The poll author picks "choose one" or "choose up to N". | Radio buttons versus checkboxes, and validation of N on the client and server. |
| Who may vote? | Anyone, without signing in, by default. Some customers turn on "signed-in readers only" using their own login. | We need a vote-once rule that works for anonymous readers, and an optional identity hook for the host. |
| How strict is one vote per person? | Best effort for casual polls. Strict for polls that pick a winner with a prize. | Two modes: a light anonymous check and a strict mode that needs a verified identity. |
| Live results? | Yes, results move while you watch, but a delay of 2 to 5 seconds is fine. | We don't need a socket per reader; batched updates are enough. |
| When are results shown? | The author picks: after voting, always, or only after the poll closes. | The widget has several result modes, and the server must not leak counts the author hid. |
| Languages and markets? | About 20 languages, including right-to-left Arabic and Hebrew. | Number formatting, plurals, and mirrored layout. |
| Size and speed budget? | Under 15 KB compressed for the first load, and no measurable change to the host's Largest Contentful Paint or Interaction to Next Paint. | No framework in the embed; lazy loading; reserved space to avoid layout shift. |
Functional requirements
- Show the poll: question, optional image, two to ten options, and a Vote button.
- Support single choice (radio buttons) and multiple choice with a maximum (checkboxes).
- After a vote, show results: a bar, a rounded percentage, and a vote count per option, plus the total.
- Mark which option the reader chose, and remember it on the next visit when possible.
- Update results live while the poll is open.
- Show a closed poll as final results with a "Poll closed" label; voting is disabled.
- Let the host match its look: colors, font, corner radius, and light or dark mode.
- Embed with one snippet; optionally let the host pass a signed-in reader's identity.
Non-functional requirements, with numbers
- Small: under 15 KB of compressed JavaScript and CSS before the poll is visible; under 30 KB in total.
- Polite to the host: no change to the host's Core Web Vitals. No layout shift (CLS stays under 0.1 for the host), no long task over 50 ms on the main thread, nothing render-blocking.
- Isolated: host CSS can't break the widget, and widget CSS can't leak into the host. A crash in the widget never breaks the host page.
- Fast vote: the chosen option and results appear within 100 ms of the click (optimistic), confirmed within about 1 second on a 4G phone.
- Fresh enough: live counts at most about 5 seconds old.
- Fair: one vote per reader as far as the chosen mode allows, and no fingerprinting.
- Accessible: WCAG 2.2 AA; works with a keyboard and screen readers; results are announced.
- Spike-proof: a poll on a front page must not take down our servers or our customer's page.
Out of scope, and why
- The dashboard where authors create polls. It is a normal app; we only need its output: a poll id and settings.
- Ranked-choice and open-text answers. They change the results view a lot, so we park them.
- The vote-counting database itself. We say what the frontend needs from it (fast reads, dedupe, atomic counters) and move on.
Check your understanding · Exercise 1
Which answers to the scope questions change the frontend design the most? Select all that apply.
What makes a poll widget hard
Build a poll as a page on your own site and it is a weekend project: a form, a POST, a bar chart. Five facts turn it into a real design question. Every later chapter comes back to one of them.
1. It is a guest in someone else's house
The widget runs on pages we don't own. The host's CSS, fonts, scripts, and security headers are all unknown. A rule like * { box-sizing: content-box } or label { display: block; text-transform: uppercase } can wreck a layout that looked perfect in our test page.
In the other direction, one careless global rule from us, like .button { color: red }, can break the host's own buttons. Every embed decision is about keeping the two worlds apart while still sharing one page.
2. It must be tiny and polite
A news article is already heavy: ads, analytics, a video player, consent banners. The publisher's SEO team watches Core Web Vitals because Google uses them in search ranking. If our poll adds 80 KB of React and a 120 ms long task, it makes the host's page worse and the customer removes it. So the budget is closer to a tracking pixel than to an app: about 15 KB before the poll shows, loaded after the host's own content, with space reserved so nothing jumps.
3. "One vote per person" with no idea who the person is
Most readers are anonymous, and we can't ask them to sign in for a two-second poll. The classic answer was a third-party cookie, but browsers now partition or block third-party storage. Fingerprinting the device is invasive, goes against the tracking-prevention policies of browsers like Safari and Firefox, and in the EU needs consent just like a cookie. So we must pick a vote-once rule that is honest about how strong it is, and offer a strict mode for polls that really need it.
4. Traffic is spiky and lopsided
A poll in a breaking-news story goes from 0 to 50,000 readers in minutes. Most of them read results; a smaller share vote. Reads outnumber writes about 10 to 1, and every reader wants the same few numbers.
That shape is a gift: the same results payload can be cached and pushed to everyone. It also means one hot poll row in a database can become a bottleneck if every vote writes to it directly.
5. The numbers must look right
Readers check the math. Three options at 33.3% each, rounded the simple way, show 33%, 33%, 33%: a total of 99%, and someone posts a screenshot. Counts must never go backwards while you watch.
The option you voted for must not show zero votes because your vote is still in flight. And 1,234 votes must read as 1.234 in German and 1 234 in French. Small things, but they are what people judge a poll by.
Think first
Which of the five facts would change most if the poll lived only on our own site, never embedded?
The first two mostly disappear. We would control CSS, fonts, and headers, so no isolation layer. Our own app bundle is already loaded, so the size budget is far looser.
Identity gets easier too: readers on our site are first-party, so a first-party cookie or a session works. Spiky traffic and correct numbers stay exactly as hard. That split tells you where an interviewer will push: on embedding and on vote-once.
The component: voting, results, and closed polls
Before the embed and the servers, get the widget itself right. It has three faces. The voting view shows the question and the options. The results view shows bars, percentages, and counts.
The closed view is the results view with voting turned off and a "Poll closed" label. A small state machine decides which face shows. It is worth drawing, because most bugs live in its edges: a vote that fails, a poll that closes while you are choosing, a reader who already voted yesterday.
The states: loading while the poll data arrives; choosing once it is shown; submitting after Vote is pressed; voted once results are on screen; closed when the poll's end time has passed or the author stopped it. Two edges matter most. From submitting, a failure goes back to choosing with the reader's selection kept and an error message shown. From choosing, a live update that says the poll closed moves straight to closed, and the reader is told why their selection vanished.
Decision How should readers pick an option?
What we pick: Native inputs in a fieldset, with a Vote button. They give correct keyboard and screen reader behavior for zero bytes, and they handle both single and multiple choice with the same markup. CSS isolation (next chapter) solves the styling worry. A host that wants one-tap voting for single-choice polls can turn on an option that submits on change, but the default protects against mis-taps.
<form class="poll">
<fieldset aria-describedby="poll-hint">
<legend>Tabs or spaces?</legend>
<p id="poll-hint">Choose up to 2. 2 of 2 chosen.</p>
<label><input type="checkbox" name="o" value="a1"> Tabs</label>
<label><input type="checkbox" name="o" value="a2"> Spaces</label>
<label><input type="checkbox" name="o" value="a3" disabled> Both</label>
</fieldset>
<button type="submit">Vote</button>
<p role="status" class="sr-only"></p>
</form>
Multiple choice with a maximum
"Choose up to 2" needs a rule for the third click. We show a live counter ("2 of 2 chosen") and disable the unchecked boxes once the maximum is reached; unchecking one enables them again. The counter is linked with aria-describedby, so a screen reader hears the limit when it enters the group. The server checks the same limit again, because a client rule is only a convenience: anyone can send a request with five option ids.
The results view
Each result row is plain text first: "Spaces, 59%, 1,204 votes". The bar is decoration drawn with a CSS width and hidden from screen readers with aria-hidden. We don't use progress, because a vote share is not a task in progress, and screen readers would say "progress bar".
The reader's own choice gets a check mark and the words "Your vote", not only a color, so it works for color-blind readers. The leading option is marked in text too, not only by the longest bar.
When the view switches from voting to results, the form disappears and focus would fall to the page body. We move focus to the results heading (with tabindex="-1"), the same rule the modal-dialog guide uses for content that replaces itself. A polite live region (role="status") announces once: "Vote recorded. Spaces leads with 59%."
Live updates after that are not announced: a screen reader that says a new percentage every 3 seconds makes the rest of the page unusable. A "Refresh results" button gives screen reader users fresh numbers when they ask.
Closed polls and hidden results
A closed poll renders the results view with no inputs and a label: "Poll closed on 12 March. Final results." If the author chose "results only after the poll closes", the voting view shows "Results will appear when the poll closes" after a vote, and the server must not send counts at all until then. Hiding counts in CSS would leak them to anyone who opens the network tab.
Check your understanding · Exercise 2
A reader votes, the form is replaced by results, and a live update arrives every 3 seconds. Which choices are right for screen reader users? Select all that apply.
Embedding: script tag, web component, or iframe
The customer pastes something into their page. What that something is decides how isolated we are, how big we are, which security rules apply, and even how vote-once can work. This is the decision interviewers most want to hear reasoned out, so take it slowly. The published lessons on third-party scripts, web components, and clickjacking and iframes cover the building blocks; here we apply them.
The naive answer is a script tag that renders the poll straight into the host's DOM: document.querySelector('#poll').innerHTML = .... It works on our test page. Then it meets a real host. Their h3 { font-size: 2.4rem } makes the question huge.
Their input { appearance: none } hides the radio buttons. Our .option class matches one of theirs and turns their menu purple. Our script throws on an old browser, and because it runs in their page, the error shows up in their error tracker. Every customer becomes a CSS support ticket.
Decision What should the customer paste, and where does the poll render?
What we pick: An iframe, created by a tiny loader script, with a script-free plain iframe snippet for strict hosts. Isolation is the requirement we can least afford to get wrong, and only the iframe gives it in both directions plus crash isolation and our own CSP. Its costs are manageable: sizing is one small message, theming goes through URL parameters, and the memory cost is fine for one or two polls per page. A web component is a good second mode for hosts who trust us and want the poll inline, and it is the right default for a first-party poll. Rendering straight into the host DOM is never the default.
The loader: 2 KB that does almost nothing
The loader script's whole job: find placeholders, wait until each is near the viewport, create the iframe, and keep its height in sync. It is loaded with async, so it never blocks the host's HTML parser. It uses an IntersectionObserver with a rootMargin of about 600px, so a poll at the bottom of a long article costs nothing until the reader scrolls toward it. The browser's own loading="lazy" on iframes does something similar, and the plain-iframe snippet uses it.
<!-- Default: loader + placeholder -->
<div class="pollx" data-poll="p_81" data-theme="dark" style="min-height:340px"></div>
<script async src="https://cdn.polls.example/embed.js"></script>
<!-- Strict hosts: no script at all -->
<iframe src="https://embed.polls.example/p/p_81?theme=dark"
title="Poll: Tabs or spaces?" loading="lazy"
width="100%" height="340"></iframe>
Sizing the iframe with postMessage
An iframe does not grow with its content. Inside the frame, a ResizeObserver watches the poll's root element and posts its height to the parent. The loader listens and sets the iframe's height.
Two checks keep this safe, as the clickjacking-and-iframes lesson explains: the loader accepts the message only if event.origin is exactly our embed origin and event.source is the contentWindow of one of our iframes. The frame, in turn, posts to "*" because it doesn't know the host's origin. So it sends only a height: never a vote, a token, or anything private.
// Inside the iframe
new ResizeObserver(([entry]) => {
const height = Math.ceil(entry.borderBoxSize[0].blockSize);
parent.postMessage({ type: "pollx:resize", v: 1, height }, "*");
}).observe(document.getElementById("poll")!);
// In the loader, on the host page
const ourFrames: HTMLIFrameElement[] = []; // filled as frames are created
addEventListener("message", (event) => {
if (event.origin !== "https://embed.polls.example") return;
const frame = ourFrames.find((f) => f.contentWindow === event.source);
if (!frame || event.data?.type !== "pollx:resize") return;
const h = Number(event.data.height);
if (Number.isFinite(h) && h > 0 && h < 2000) frame.style.height = h + "px";
});
event.origin is our embed origin and event.source is one of our frames. The forged resize at the bottom fails both checks.Layout shift is the hidden cost. The placeholder's min-height comes from the poll's settings (we know how many options it has, so the dashboard puts a good estimate in the snippet). The first resize then moves little or nothing. A resize after a vote is fine for Cumulative Layout Shift, because shifts within 500 ms of a user's input are not counted.
Content Security Policy on both sides
Many news sites send a Content Security Policy (CSP): a header that lists where scripts, frames, and requests may come from. With the loader, the host must allow our CDN in script-src and our embed origin in frame-src. With the plain iframe, only frame-src. The dashboard shows the exact CSP lines to add, because "the poll is blank" is almost always a CSP error in the host's console.
Our iframe document sends its own strict CSP. Because its HTML is cached at the CDN, the policy uses script hashes rather than nonces: a nonce must be fresh in every response, and a cached page would repeat it. It also sends frame-ancestors to say who may frame it: * for public polls, or the customer's listed domains for polls that should only appear on their site.
The host controls one more thing: the sandbox attribute on the iframe tag. Each token gives back one power, so a careful host that sandboxes too hard breaks the poll. Our docs list exactly what it needs: allow-scripts, allow-forms for the no-script fallback, and allow-same-origin so its cookie and storage work. Scripts plus same-origin is a known trap, but only when the framed page is same-origin with the host; ours never is.
Check your understanding · Exercise 3
Which statements about the iframe embed are true? Select all that apply.
One vote per person, without tracking anyone
"One vote per person" sounds like a database constraint. The hard part is the word "person". Most readers are anonymous, many use phones with private browsing, and the browser actively stops a third-party frame from recognizing the same reader across sites.
Be honest early in the interview: for anonymous readers we can make voting twice inconvenient, not impossible. For polls where it truly matters, we need a real identity.
The naive design stores voted_p_81 = true in localStorage and hides the Vote button. It stops honest double-clicks and nothing else. A private window, a second browser, or one line in the console resets it, and the server never knew. Worse, if the server trusts the client, a script can POST 10,000 votes in a loop.
What the browser lets a third-party frame remember
Our iframe is third-party: its origin (embed.polls.example) belongs to a different site from the top-level page (news.example). Browsers treat its storage differently from first-party storage, as the client-storage lesson explains.
Safari blocks third-party cookies by default. Firefox gives each top-level site its own separate cookie jar (Total Cookie Protection). Chrome still allows them by default, but blocks them in Incognito windows and lets users block them everywhere. On top of that, all three browsers partition localStorage, IndexedDB, and similar storage in third-party frames by top-level site.
The practical result: whatever our frame stores on news.example is a different box from what it stores on blog.example. A reader who sees the same poll embedded on two sites looks like two readers. That's acceptable: nearly every poll is embedded on one site, and "one vote per person per site" is a fair promise.
What about the Storage Access API, where the frame calls document.requestStorageAccess() after a click to get its unpartitioned cookies? Browsers deny it unless the reader has recently used our site as a top-level page, and they may show a prompt. Our readers never visit polls.example itself, so it doesn't help here. Design for partitioned storage everywhere, and treat an unpartitioned cookie as a bonus that may vanish.
Decision How do we recognize a returning anonymous voter?
What we pick: Tiers. Every poll uses the partitioned voter cookie with server-side dedupe, plus the client flag purely to show "Your vote" fast. Polls marked strict require the host-verified identity and refuse anonymous votes. We say plainly in the dashboard which tier a poll uses, so a customer running a prize draw doesn't rely on a cookie.
Handing the host's token to the frame
For strict polls, the frame posts pollx:ready to the parent. The loader replies with { type: "pollx:identity", token }. The frame accepts it only if event.source is its parent window and event.origin is in the poll's list of allowed host origins, which came with the poll data from our server.
We don't put the token in the iframe URL: URLs end up in server logs, browser history, and Referer headers. The token lives only in memory and expires in minutes.
Dedupe versus idempotency
Two different problems hide here. Dedupe stops a second, deliberate vote from the same voter. Idempotency makes one vote safe to send twice: the phone loses signal after the request left, the client retries, and the server must not count it twice. Dedupe alone almost covers this, but not for a voter allowed to change their answer, where a late retry of the old choice would undo the change.
So each vote attempt gets an id from crypto.randomUUID(), sent as an Idempotency-Key header. Retries of the same attempt reuse it; a new choice gets a new one. The server remembers each key with its response for 24 hours and replays the stored response for a repeat. The retries-and-backoff lesson covers the backoff schedule; for a vote, three tries over about 10 seconds is plenty.
Check your understanding · Exercise 4
A customer asks why a reader could vote twice. Which explanations are plausible with our default tier? Select all that apply.
Voting: optimistic, with an honest rollback
A reader presses Vote. The simple design disables the button, shows a spinner, waits for the server, then shows results. On office Wi-Fi that's 150 ms and nobody notices.
On a phone at a train station during a traffic spike, the round trip is 1 to 3 seconds, and a spinner on a two-second poll feels broken. Readers tap again, or scroll away before seeing results, which was the whole reward for voting.
Decision When should the results appear after a vote?
What we pick: Optimistic, with a few quick retries and no long-lived queue. A vote is cheap, almost always succeeds, and the reward is seeing the results, so showing them at once is worth the rollback code. Queuing for hours buys little: if the reader is offline that long, telling them "Your vote didn't go through" is more honest than counting it later. The optimistic-updates lesson covers the general pattern; here we focus on the poll's specific failure cases.
Each failure, and what the reader sees
| Server says | Meaning | What the widget does |
|---|---|---|
200 with results | Counted. | Replace local counts with the server's counts and version. Keep "Your vote". |
409 already voted | This voter id voted before, maybe in another tab. | Show the server's results and the vote it has on record: "You already voted for Tabs." |
410 poll closed | Closed while the reader was choosing. | Switch to the closed view with final results: "This poll closed before your vote arrived." |
429 too many requests | Rate limit hit. | Roll back to choosing, keep the selection, show "Too many votes from your network. Try again in a minute." |
Network error or 5xx | Unknown: maybe counted, maybe not. | Retry with the same idempotency key at about 1, 3, and 6 seconds. If all fail, roll back with "Your vote didn't go through" and a Try again button. |
Notice that a network error is unknown, not failed. The request may have reached the server. That is exactly why retries reuse the idempotency key: if the first attempt was counted, the retry gets the stored 200 back and nothing is counted twice.
Merging local votes with live updates
Here is the subtle bug. The reader votes for Spaces; we show Spaces at 1,205 (server said 1,204, plus our 1). Before the server confirms, a live update arrives saying Spaces is at 1,210.
If we simply show the update, our vote seems to vanish from it. Or is it already inside the 1,210? We can't tell from the numbers.
The fix is a version. Every results snapshot from the server carries a number that only goes up. The vote response says which version first includes our vote, called includedFrom in the code below.
Until the widget has a snapshot at that version, it shows the latest snapshot plus our pending vote. After that, it shows snapshots as they are. And it ignores any snapshot older than the one it has, so counts never jump backwards when a slow response arrives late.
type Snapshot = { version: number; counts: Record<string, number> };
type Pending = { optionIds: string[]; includedFrom?: number };
function shown(latest: Snapshot, pending?: Pending): Record<string, number> {
if (!pending) return latest.counts;
const inside = pending.includedFrom !== undefined && latest.version >= pending.includedFrom;
if (inside) return latest.counts;
const counts = { ...latest.counts };
for (const id of pending.optionIds) counts[id] = (counts[id] ?? 0) + 1;
return counts;
}
function accept(current: Snapshot, next: Snapshot): Snapshot {
return next.version > current.version ? next : current; // never go backwards
}
Check your understanding · Exercise 5
The vote request times out with no response. What should the widget do? Select all that apply.
Live results: polling, server-sent events, or WebSockets
After voting, readers like to watch the bars move. The requirement says a delay of 2 to 5 seconds is fine. That one sentence decides most of this chapter, so it is worth confirming in the interview: "Is a few seconds of delay acceptable?" For a poll in an article, yes. For a live TV vote with a countdown, maybe not.
Do the math before choosing a transport. A hot poll has 50,000 readers with results on screen and 2,000 votes per second at peak. If we push every vote to every reader, that's 2,000 times 50,000: 100 million messages per second, for numbers a human reads once every few seconds.
So whatever the transport, the server sends snapshots, not votes: the full set of counts, at most once a second. Then the cost is 50,000 messages per second, and each message is about 200 bytes.
| Approach | Load at 50,000 viewers | Delay readers see | Main cost |
|---|---|---|---|
| Polling every 3 s, no cache | About 17,000 requests per second to our servers | About 1.5 s on average, 3 s at worst | Origin load grows with every viewer. |
| Polling every 3 s, CDN cache of 2 s | Roughly one request every 2 s per CDN location reaches us | About 2.5 s on average, usually under 5 s | Slightly staler data; nearly free at any scale. |
| SSE, snapshot once a second | 50,000 open connections; 50,000 small messages per second | About 1 s | Long-lived connections and a fan-out tier. |
| WebSocket, snapshot once a second | Same as SSE | About 1 s | Same, plus a custom protocol, reconnect logic, and no HTTP caching. |
Decision How should results reach readers who are watching?
What we pick: Short polling a CDN-cached results URL by default, and server-sent events for polls the author marks as "live event". This matches the choosing-realtime lesson: start with polling when a few seconds is fine and many people read the same data, and move to SSE when updates must feel instant. WebSockets add two-way plumbing we don't need. Both transports sit behind one small ResultsSource interface that delivers snapshots, so switching is a setting, not a rewrite.
Only fetch what someone is looking at
- Not voted yet, results hidden until voting: no live updates at all. Most readers never vote, so this alone removes most of the traffic.
- Out of view: an
IntersectionObserverinside the frame reports when the poll is scrolled off screen; it works in cross-origin frames against the top-level viewport. Stop polling or close the stream after about 10 seconds out of view. - Tab hidden: on
visibilitychangetohidden, stop. On return, fetch once at once, then resume. - Poll closed: the last snapshot says
closed: true; stop for good. Closed results are served with a long cache time. - Errors: back off with jitter and show a quiet "Updated 1 minute ago" instead of spinning.
One more detail from the server side, because it shapes the payload. A single counter row that 2,000 votes per second all increment becomes a lock fight in most databases. The usual fix is sharded counters: each option has, say, 20 counter rows, a vote increments a random one, and an aggregator sums them once a second into a snapshot with a new version number.
The frontend never sees the shards. It sees one versioned snapshot, which is exactly what the merge logic in the previous chapter needs.
Check your understanding · Exercise 6
A big publisher embeds a poll in its front page story. Which steps keep live results cheap without making them feel stale? Select all that apply.
Percentages that add up to 100, in every language
Rounding sounds like the least interesting part of the design. It is also the part readers screenshot. Take a three-way tie, one vote each: each share is 33.33%, and rounding each one to the nearest whole number gives 33%, 33%, 33%, a total of 99%.
Seven equal options give 14% each: 98%. Six give 17% each: 102%. Nothing is wrong with any single number, but the column doesn't add up, and to a reader that looks like a bug or a rigged poll.
The fix is a classic from elections: the largest remainder method (also called the Hamilton method). Give each option the whole-number part of its share. Then count how many points are missing to reach 100, and hand them out one each to the options with the largest leftover fractions. Each option ends within 1 point of its exact share, and the total is always exactly 100.
function roundShares(counts: number[], decimals = 0): number[] {
const total = counts.reduce((sum, c) => sum + c, 0);
if (total === 0) return counts.map(() => 0);
const scale = 100 * 10 ** decimals; // 100 points, or 1000 tenths
const floors = counts.map((c) => Math.floor((c * scale) / total));
const remainders = counts.map((c) => (c * scale) % total);
let left = scale - floors.reduce((sum, f) => sum + f, 0);
const order = counts
.map((_, i) => i)
.sort((a, b) => remainders[b] - remainders[a] || counts[b] - counts[a] || a - b);
for (const i of order) {
if (left === 0) break;
floors[i] += 1;
left -= 1;
}
return floors.map((f) => f / 10 ** decimals);
}
| Votes | Exact shares | Round each | Largest remainder |
|---|---|---|---|
| 1, 1, 1 | 33.33, 33.33, 33.33 | 33, 33, 33 (99) | 34, 33, 33 (100) |
| 2, 2, 2, 1 | 28.57, 28.57, 28.57, 14.29 | 29, 29, 29, 14 (101) | 29, 29, 28, 14 (100) |
| 1 each, 7 options | 14.29 each | 14 each (98) | 15, 15, then 14 five times (100) |
| 1, 999 | 0.1, 99.9 | 0, 100 (100) | 0, 100 (100), shown as below 1% and 100% |
| 0, 0, 0 | undefined | NaN on screen | 0, 0, 0 with "No votes yet" |
The details that matter
- Ties need a rule. When remainders are equal, the code gives the extra point to the option with more votes, then to the earlier option in the author's order. Any rule works if it is the same on every device, so two readers never see different numbers for the same counts.
- Integer math.
(c * scale) % totalis exact. Floating-point remainders like33.333333333333336can break ties differently on different inputs. - A real vote never shows as 0%. One vote out of 1,000 rounds to 0. Show it as "below 1%" so the reader who cast it doesn't think it was lost.
- Bars use the exact share. Only the printed number is rounded. The bar width is
count / total, so a 0.4% option still shows a sliver. - Multiple choice is different. If readers may pick 2 options, shares are "% of voters who picked this" and add up to more than 100. Don't force them to 100; label them "of 1,204 voters" so the reader knows why.
- Round on the client. The server sends raw counts. The widget rounds, because it knows the display precision and recomputes on every live snapshot.
Numbers and plurals in 20 languages
The same counts must read naturally everywhere. The browser's Intl APIs do this with no library, which matters in a 15 KB budget. The locale-formatting lesson goes deep; for the poll, four things matter.
| Locale | 1204 votes | 58% share | 1,234,567 compact | Plural categories |
|---|---|---|---|---|
en-US | 1,204 | 58% | 1.2M | one, other |
de-DE | 1.204 | 58 % | 1,2 Mio. | one, other |
fr-FR | 1 204 | 58 % | 1,2 M | one, many, other (0 is "one") |
ar-EG | Arabic-Indic digits | Arabic percent sign | Arabic word for million | zero, one, two, few, many, other |
- Counts and percents:
new Intl.NumberFormat(locale).format(1204)and{ style: "percent" }with the share as a fraction (0.58). Never build "58" + "%" by hand: German and French put a space before the sign. - Plurals:
new Intl.PluralRules(locale).select(n)picks the message: English has 2 forms, Arabic has 6, and in French 0 takes the singular ("0 vote"). Translators write one string per category. - Big totals:
{ notation: "compact" }turns 1,234,567 into "1.2M" so the total fits on a phone. - Right-to-left: for Arabic and Hebrew the frame sets
dir="rtl"and the CSS uses logical properties (inline-size,margin-inline-start), so bars grow from the right with no second stylesheet.
Which locale? The frame can't read the host page, so the loader reads the host's lang attribute on the html element and passes it as a URL parameter. If the host sets none, the frame falls back to navigator.languages. The poll's own text comes from the author in their language; only our UI strings ("Vote", "Poll closed", "votes") are translated by us.
Check your understanding · Exercise 7
Votes are 2, 2, 2, 1 in a single-choice poll. Which statements are true? Select all that apply.
Architecture: who does what, and why the lines are there
With the big decisions made, we can draw the pieces. There are three places code runs: the host page (only our 2 KB loader), the iframe (the widget itself), and our servers behind a CDN. The boundaries follow the trust lines: the host page is not ours, the frame is ours but runs on readers' devices, and only the servers are trusted to count.
| Part | Runs in | Responsibility |
|---|---|---|
Loader (embed.js) | Host page | Find placeholders, lazy-create iframes, apply heights from resize messages, pass lang, theme, and an optional identity token. Nothing else. |
| Frame shell (HTML) | Iframe | Server-rendered poll: question, options, and results if visible, with critical CSS inline. Readable before any script runs. |
| Poll store | Iframe | The state machine (loading to closed), the latest snapshot, the pending vote, and the merge rule. |
| Views | Iframe | Voting, results, and closed views. Pure functions of the store. |
| API client | Iframe | fetch calls with idempotency keys, timeouts through AbortController, and retries with backoff. |
| Results source | Iframe | One interface, two transports: CDN polling or EventSource. Pauses when hidden or off screen. |
| Bridge | Iframe | All postMessage traffic: resize out, identity and theme in, analytics events out. Checks origins. |
| Formatter | Iframe | Intl number, percent, plural, and date formatting, plus roundShares. |
| Edge and CDN | Our network | Static assets with long cache, poll HTML with short cache, results JSON with a 2 s cache. |
| Vote API | Our servers | Validate, rate-limit, dedupe, count, and return the new snapshot version. |
| Aggregator and stream | Our servers | Sum sharded counters once a second into a versioned snapshot; publish it to the cache and to SSE subscribers. |
The first load, step by step
- The host's HTML arrives with a placeholder that reserves 340px, and our
asyncloader script (about 2 KB compressed, cached for a year under a versioned file name, behind a short-livedembed.jsalias). - The loader runs after the host's own work. An
IntersectionObserverwaits until the placeholder is within about 600px of the viewport. - The loader creates the iframe pointing at
/p/p_81?lang=de&theme=dark. The CDN answers with HTML that already contains the question and options, cached for 30 seconds. - The poll is visible and usable with the native form even before the 8 KB script runs. The script then attaches the store, upgrades the form to optimistic voting, and posts the real height.
- If the reader voted before, a
localStorageflag in the frame shows results at once; the server confirms on the next fetch.
Why server-render the frame instead of shipping a JavaScript app that fetches the poll? Because a client-rendered widget needs three round trips before anything shows: the HTML, the script, then the poll data. Rendering the poll into the HTML at the edge makes it one. On a 4G phone with 150 ms round trips, that is roughly 300 ms saved, and the poll shows even if our script fails.
Why no framework? React with ReactDOM adds over 40 KB compressed, more than our whole first-load budget. The widget has three views and one store. A few hundred lines of plain TypeScript, or a library of a few KB such as Preact if the team prefers components, does the job.
The dashboard where authors build polls can use whatever the rest of the company uses; that boundary is another reason to keep it a separate app.
Check your understanding · Exercise 8
Which responsibilities belong in the loader that runs on the host page? Select all that apply.
Data model: what the widget holds, and where
The widget's data is small, but each piece has a different owner, lifetime, and trust level. Sorting them out is what keeps the merge logic and the vote-once rule simple. There are four kinds: the poll definition (written by the author, changes rarely), the results snapshot (changes every second), the reader's vote (theirs, private), and UI state (this frame only).
type Poll = {
id: string; question: string; options: { id: string; label: string }[];
mode: { kind: "single" } | { kind: "multiple"; max: number };
showResults: "after-vote" | "always" | "after-close";
closesAt?: string; closed: boolean; strict: boolean;
allowedOrigins: string[]; locale: string; transport: "poll" | "sse";
};
type Snapshot = { version: number; total: number; voters: number;
counts: Record<string, number>; closed: boolean };
type MyVote = { optionIds: string[]; status: "pending" | "confirmed";
attemptKey: string; includedFrom?: number };
type UiState = {
phase: "loading" | "choosing" | "submitting" | "voted" | "closed";
selection: string[]; error?: "network" | "rate-limited" | "closed";
};
| Data | Lives in | Lifetime | Why there |
|---|---|---|---|
| Poll definition | Server; rendered into the frame HTML; CDN for 30 s | Until the author edits it | Same for everyone, so cache it at the edge. |
| Results snapshot | Server aggregator; CDN for 2 s; frame memory | About a second | Same for everyone; versioned so the frame can ignore old ones. |
| Voter id | HttpOnly partitioned cookie on our origin | About a year, per top-level site | The server needs it for dedupe; page scripts don't. |
| My vote (record of truth) | Server, keyed by poll and voter | Forever, or as long as the poll | Only the server can enforce one vote. |
| My vote (hint) | localStorage in the frame (partitioned) | Until cleared | Shows "Your vote" instantly on return; never trusted. |
| Pending vote and attempt key | Frame memory | Seconds | Needed only while the request is in flight. |
| UI state | Frame memory | This page view | Nobody else needs it. |
| Theme and locale | Frame URL parameters | This page view | Set by the host; makes the HTML cacheable per variant. |
Note total and voters in the snapshot. In a single-choice poll they are equal. In a multiple-choice poll, one voter adds up to max to total, and the percentages must be computed from voters, as the percentages chapter explained. Keeping both from the server avoids a client guess that would be wrong.
Also note what is not in the client model: no list of individual votes, no other voters' ids, no IP addresses. The frame never needs them, so the API never sends them. A smaller payload is also a smaller privacy surface.
Check your understanding · Exercise 9
Where should the widget keep the fact that this reader voted for option a2? Select all that apply.
Interface: the HTTP API, the stream, and the embed contract
The widget talks to three audiences: our servers (HTTP and a stream), the host page (postMessage and snippet attributes), and the author's dashboard (which creates polls). Each needs a small, stable contract, because thousands of pasted snippets can't be updated when we change our minds. Version everything that crosses a boundary.
HTTP endpoints
| Method and path | Purpose | Caching |
|---|---|---|
POST /v1/polls | Dashboard creates a poll. Returns the id and the snippets. | None; authenticated author. |
GET /p/{id}?lang&theme | The frame's server-rendered HTML. | CDN, max-age=30, varies by the query string. |
GET /v1/polls/{id}/results | The latest snapshot as JSON. Sends ETag; answers 304 if unchanged. | CDN, max-age=2, stale-while-revalidate=5. |
GET /v1/polls/{id}/stream | SSE: a results event per snapshot, for live-event polls. | Not cached; fan-out tier. |
POST /v1/polls/{id}/votes | Cast or change a vote. Needs Idempotency-Key. | Never cached; reads the voter cookie. |
GET /v1/polls/{id}/me | This voter's vote, if any. | private, no-store. |
POST /v1/polls/p_81/votes
Content-Type: application/json
Idempotency-Key: 6f1c2e0a-4b7d-4c1e-9d2a-7b3e5f0a1c88
Cookie: voter_id=v_9Qx...
{ "optionIds": ["a2"], "identity": null }
HTTP/1.1 200 OK
Content-Type: application/json
{ "vote": { "optionIds": ["a2"], "includedFrom": 5120 },
"results": { "version": 5120, "total": 2045, "voters": 2045,
"counts": { "a1": 830, "a2": 1205, "a3": 10 }, "closed": false } }
{
"error": {
"code": "already_voted",
"message": "This voter already voted in poll p_81.",
"retryable": false,
"vote": { "optionIds": ["a1"] },
"results": { "version": 5121, "total": 2046, "voters": 2046,
"counts": { "a1": 831, "a2": 1205, "a3": 10 }, "closed": false }
}
}
The error carries results too. A 409 already_voted or 410 poll_closed is not a dead end: the widget can show the true state right away without a second request. retryable tells the API client whether backoff makes sense (503, network) or not (409, 410, 422 too_many_options). A 429 carries a Retry-After header.
The SSE stream
event: results
id: 5121
data: {"version":5121,"total":2046,"voters":2046,"counts":{"a1":831,"a2":1205,"a3":10},"closed":false}
event: results
id: 5122
data: {"version":5122,"total":2051,"voters":2051,"counts":{"a1":833,"a2":1208,"a3":10},"closed":false}
retry: 5000
The embed contract: attributes and messages
| Name | Direction | Shape | Rule |
|---|---|---|---|
data-poll | Snippet to loader | Poll id | Required. |
data-theme, data-accent, data-radius | Snippet to loader to frame URL | light, dark, auto; a hex color; a pixel value | Validated; unknown values ignored. |
pollx:ready | Frame to host | { v: 1, pollId } | Sent once the frame can receive messages. |
pollx:resize | Frame to host | { v: 1, height } | Host checks origin and source; clamps the value. |
pollx:identity | Host to frame | { v: 1, token } | Frame accepts only from the poll's allowed origins. |
pollx:event | Frame to host | { v: 1, name: "vote", optionIds } | Lets the host's own analytics count votes. No voter ids. |
The v: 1 field matters more than it looks. A snippet pasted in 2026 may run against our frame in 2029. The loader and the frame each ignore message versions they don't know, and we keep old versions working for years.
In web component mode, the same settings are attributes (poll-id, theme) and the events are CustomEvents such as pollx-vote, dispatched on the poll-widget element itself so host listeners hear them. An event fired from inside the shadow root would need bubbles: true, composed: true to cross the boundary.
Check your understanding · Exercise 10
Which API choices help the widget at a traffic spike? Select all that apply.
Abuse, rate limits, and security
Any public counter gets attacked. A forum decides to "win" a poll on a rival site. Someone writes a 10-line script that clears cookies and votes in a loop. A political poll gets a few thousand votes from one data center at 3 a.m.
We can't stop a determined attacker with anonymous voting, and we said so in the vote-once chapter. What we can do is make cheating slow, visible, and cheap to clean up, while honest readers never notice.
The naive defense is a CAPTCHA on every vote. It does stop simple scripts, but it turns a two-second interaction into a thirty-second puzzle, many readers give up, and it adds a large third-party script to a widget with a 15 KB budget. The friction lands on everyone to stop a few.
Decision How do we slow down vote bots without hurting honest readers?
What we pick: Rate limits for everyone, a challenge only for suspicious traffic, and strict identity for polls with prizes. The frontend's part is small but real: it must handle 429 with Retry-After gracefully, show a challenge in the frame when the API asks for one (loaded lazily, never in the main bundle), and never reveal the scoring rules in client code, where an attacker would read them.
Validate on the server, every time
The input-validation lesson's rule applies directly: client checks are for helpfulness, server checks are for truth. The vote API checks that every option id belongs to this poll, that there are no duplicates, that the count is within max, that the poll is open at the server's clock (not the reader's), and that a strict poll has a valid, unexpired identity token for this poll id. Each failure gets its own error code so the widget can explain it.
Author text is untrusted text
Poll questions and options are written by thousands of customers, and some accounts will be compromised. The frame renders them with textContent, never innerHTML, so a script tag typed into a question is just characters on screen. The frame's own CSP (script-src 'sha256-...' 'strict-dynamic'; object-src 'none'; base-uri 'none') is a second wall. Option images go through our image service, which re-encodes them and serves fixed sizes, so a poll can't load an arbitrary third-party URL that tracks readers.
Who may frame us
A malicious page could embed our poll, make it transparent, and place it over a fake button, so readers vote without knowing: classic clickjacking. For a public poll the damage is a few skewed votes, and we can't forbid framing because framing is the product. For strict polls, the server sends Content-Security-Policy: frame-ancestors with the customer's domains, so the browser refuses to render the poll anywhere else. The clickjacking-and-iframes lesson shows the attack in detail.
Check your understanding · Exercise 11
Which defenses belong in the frame's client code? Select all that apply.
Theming, performance, and analytics
The last chapter before the interview covers what customers notice after launch: does the poll look like part of my site, did my page get slower, and how many people voted? Each has a trap specific to living in someone else's page.
Theming with CSS custom properties
Host CSS can't reach into our frame, which is the point. So the host describes its look with a few settings, and the frame maps them to CSS custom properties: --poll-accent, --poll-radius, --poll-surface, --poll-text. All widget CSS reads only these variables, so a theme is just different values.
We offer a short, validated list rather than "paste any CSS": a hex color must match a strict pattern, a radius must be 0 to 24 pixels. Free-form CSS would reopen the breakage we just closed, and could be used to hide the vote button or fake a result.
- Dark mode:
theme=autousesprefers-color-schemeinside the frame. But the host may be dark while the reader's system is light, so hosts that know their own theme should passlightordark. Set the CSScolor-schemeproperty to match, so form controls and scrollbars use the right colors and the frame background blends in. - Fonts: the frame can't use the host's web fonts. We use the system font stack (
system-ui) by default, which costs zero bytes and looks native on every device. Downloading a font would add 20 KB or more and a flash of changing text. - Web component mode: custom properties cross the shadow boundary, so hosts set
poll-widget { --poll-accent: #0a7 }in their own CSS. We also expose a few::part()names (option,bar,vote-button) for deeper changes, and nothing else. - Contrast: if the host's accent color fails 4.5:1 contrast against the surface, the frame darkens or lightens it for text and keeps the original for the bar. Customers pick brand colors; we keep the text readable.
The performance budget, line by line
| Piece | Budget (compressed) | When it loads |
|---|---|---|
Loader embed.js | 2 KB | async, after the host's HTML; cached for a year under a versioned name. |
| Frame HTML with inline critical CSS | 6 KB | When the poll nears the viewport. |
| Frame script (store, views, API client, formatter) | 8 KB | defer in the frame; the poll is already visible. |
| SSE or challenge code | 3 to 15 KB | Only for live-event polls or suspicious traffic, by dynamic import(). |
- LCP: our loader is
asyncand the frame is created lazily, so we never compete with the host's hero image or headline. A poll is rarely the largest element; if it is, the server-rendered HTML makes it paint quickly. - CLS: the reserved
min-heightmakes the first resize small or zero. Later resizes follow user input and don't count. Shifts inside our frame count toward the host's CLS in CrUX too, which is one more reason the frame's HTML arrives already laid out. - INP: interactions inside our frame count toward the host page's INP in Chrome's field data (CrUX), even though the host's own scripts can't see them. Desktop Chrome and Firefox usually run a cross-site iframe in its own process (site isolation), but many phones don't, so the vote handler stays tiny: update the store, render one view, send the request. No long tasks.
- Animation: bars grow with
transform: scaleX()(composited, smooth) instead of animatingwidth. Underprefers-reduced-motion: reduce, they appear at their final size. - Memory: each iframe is a whole document, often several MB, and more when the browser gives it its own process. Two polls on a page is fine; a forum thread with 40 polls should use a lighter mode that loads results only on demand.
Analytics without tracking
Authors want to know how many people saw the poll and how many voted. We count an impression when at least half the poll is visible for one second, measured with an IntersectionObserver in the frame. Votes come from the server.
Events are batched and sent with navigator.sendBeacon, which survives the tab closing. They carry the poll id, the event name, coarse device type, and timing, never a voter id or a cross-site identifier. Our own reliability numbers ride on the same channel: time from frame start to visible poll, vote success rate, and error counts by code.
Hosts often run a consent banner. The loader passes the host's consent state to the frame through the snippet (data-consent="analytics"), and without it we send only anonymous aggregate counts. The pollx:event message lets the host feed votes into its own analytics, under its own consent rules.
Check your understanding · Exercise 12
A customer says "the poll made our article slower". Which checks are worth doing first? Select all that apply.
Running it in the interview
In 45 minutes you can't say everything in this guide. The interviewer is checking whether you spot the two things that make this problem different from a form on your own site (living in someone else's page, and vote-once without identity) and whether you reason about them with numbers. Spend your time there.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask the scope questions: who embeds, traffic, single or multiple choice, who may vote, how live, any prizes. | "Is this embedded on sites we don't own? That changes almost everything." |
| 5 to 8 | Write the requirements with numbers: 15 KB, no CLS, 5 s freshness, 2,000 votes per second. | "The budget is closer to a tracking pixel than an app." |
| 8 to 18 | Embedding decision: draw script, web component, iframe; pick the iframe plus loader; show the resize message with origin checks. | "Isolation in both directions is the one thing I can't fix later." |
| 18 to 26 | Vote-once: partitioned storage, the tiers, dedupe versus idempotency. | "For anonymous readers we make double voting inconvenient, not impossible. Prize polls need real identity." |
| 26 to 33 | Architecture diagram, then the optimistic vote flow with its failure cases. | "A timeout is unknown, not failed, so the retry reuses the key." |
| 33 to 40 | Live results: the 100 million messages math, snapshots, CDN polling versus SSE. Rounding to 100. | "Same numbers for everyone, so the CDN does the fan-out." |
| 40 to 45 | Accessibility, theming, abuse; then follow-ups. | "Native radios in a fieldset, focus to results, one polite announcement." |
Draw the architecture early, right after the embed decision, and keep adding to it. Interviewers remember a clear picture with the trust lines marked (host page, frame, servers) more than any single detail. Write the three numbers that drive the design (15 KB, 2,000 votes per second, 5 s freshness) in a corner of the board and point at them when you decide.
Follow-up questions to expect
Think first
The publisher asks: "Will Google see the poll question in our article if it's in an iframe?" What do you answer?
Google may index a frame's content as part of the article that embeds it, but it doesn't promise to, so the safe answer is: don't count on it. If the question matters for the article's SEO, the publisher should also have it in their own text, for example as the heading right above the poll. Our poll pages can send noindex together with Google's indexifembedded rule, so they never show up as standalone search results but may still count when embedded.
Alternatively, the web component mode renders inside the host's page; or the publisher can render the question as plain text in the article and show only the options in the frame. Name the trade-off: inline rendering costs us the isolation we chose the iframe for.
Think first
Product wants readers to be able to change their vote. What changes in the design?
The server keeps one vote per (poll, voter) and treats a new vote as a change: decrement the old options, increment the new ones, in one transaction or as two counter operations with the same snapshot version.
The client's pending vote now has a "previous" too, so the optimistic display subtracts the old choice and adds the new one. Each change is a new attempt with a new idempotency key, so a late retry of the old choice can't undo the new one. And results must allow counts to go down for an option, while versions still only go up.
Think first
A TV show runs a live vote with 2 million viewers and a 60-second window, and wants results on screen within a second. What do you change?
Switch this poll to the SSE transport through a fan-out tier sized for 2 million connections, with snapshots once a second. Pre-warm it before the show, because connection storms happen when the host says "vote now".
Votes go to an edge endpoint that appends to a queue and answers at once; the sharded counters are fed from the queue. The close is enforced by the server clock, and the final result is published only after the queue drains, as a separate "final" snapshot.
Check your understanding · Exercise 13
Which moves make a strong impression in this interview? Select all that apply.
Summary
- An embeddable widget is a guest: isolate it in both directions. An iframe created by a 2 KB lazy loader gives CSS, JavaScript, crash, and CSP isolation; a plain iframe snippet serves the strictest hosts.
- Size the iframe with
postMessageand checkevent.originandevent.sourceon every message. Reserve height in the snippet so the first load causes no layout shift. - Third-party storage is partitioned by top-level site and often blocked. Anonymous vote-once is best effort: a partitioned
HttpOnlyvoter cookie plus server dedupe. Prize polls need host-verified identity. Never fingerprint. - Dedupe stops a second vote; an idempotency key makes one vote safe to retry. A timeout is unknown, not failed.
- Vote optimistically, roll back honestly per error code, and merge local votes with live snapshots using a version that only goes up.
- Push snapshots, not votes. With the same numbers for everyone, a CDN-cached results URL is the cheapest fan-out; use SSE for live events, and skip WebSockets.
- Round with the largest remainder method so single-choice shares add up to exactly 100; draw bars from exact shares; don't force multiple-choice shares to 100.
- Native radios and checkboxes in a fieldset, focus moved to results, and one polite announcement give accessibility for free. Format with
Intlfor numbers, percents, and plurals.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: Window.postMessage() The security notes on checking origin and source.
- MDN: State Partitioning Why a third-party frame's storage is keyed by the top-level site.
- MDN: Cookies Having Independent Partitioned State (CHIPS) The
Partitionedcookie attribute. - WebKit: Features for Safari 26.2 Safari's return to partitioned cookies, opt-in through CHIPS.
- MDN: Storage Access API Why a frame can't get its unpartitioned cookies without prior first-party use.
- MDN: Using shadow DOM For the web component embed mode.
- MDN: CSP frame-ancestors Controlling who may frame the poll.
- web.dev: Best practices for using third-party embeds Lazy loading and layout shift for embeds.
- web.dev: Cumulative Layout Shift Including the 500 ms input exclusion.
- web.dev: Interaction to Next Paint Why interactions inside iframes still count toward the page's INP.
- web.dev: Mitigate cross-site scripting with a strict CSP Hash-based policies for cached pages.
- WAI-ARIA APG: Radio Group pattern What native radios give you, and what custom ones must re-create.
- MDN: Intl.PluralRules
- IETF draft: The Idempotency-Key HTTP header
Concepts behind it
- Web components and shadow DOMThe browser's own component model: custom HTML tags with their own lifecycle, a shadow tree that keeps their markup and styles private, and slots for the page's content, so one component works in React, Vue, Angular, or plain HTML.
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- 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.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.