cf.completefrontend
My progress
SYSTEM DESIGN · #52

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.

QuestionAnswer we assumeWhat 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.
Wireframe of the poll widget inside a host article. On the left, the voting view: the question Tabs or spaces? as the legend, three radio options Tabs, Spaces, and Both, with Spaces selected, and a Vote button. On the right, the results view: each option with a bar, a rounded percentage, and a count: Tabs 41% 830 votes, Spaces 59% 1,205 votes marked Your vote, Both 0% shown as below 1% with 10 votes, then a total of 2,045 votes and an Updated 2 s ago note. Notes name the fieldset and legend, the live status region, and the text-first results.news.example: votingTabs or spaces?TabsoSpacesBothVotestatus region (polite)news.example: resultsTabs or spaces?41% 83059% 1,205Spaces: your votebelow 1% 102,045 votesupdated 2 s agofieldset + legendfocus to resultsbars are decoration
Two faces of one small component. The results side is text first: the bar is decoration, and the reader's own choice is marked in words, not only by color.

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.

What makes a poll widget hard. In the middle, the poll widget. Around it, five notes: a guest on pages we don't own; tiny, about 15 KB, and polite to Core Web Vitals; one vote with no identity, since third-party storage is partitioned; spiky traffic, 2,000 votes a second and 10 reads per write; and numbers that must look right: sum to 100, never go backwards, and read well in every locale.poll widgetquestion + votes1. a guest onpages we don't own2. tiny: 15 KB,polite to CWV3. one vote, butno identity4. spiky: 2,000votes a second5. numbers thatadd up to 100
The first two facts disappear if the poll lives on your own site; the last three don't. That split tells you where the interview will go.

Think first

Which of the five facts would change most if the poll lived only on our own site, never embedded?

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.

State machine of the poll widget. loading goes to choosing when the poll is ready, or to closed if the poll already ended. choosing goes to submitting when Vote is pressed. submitting goes to voted on success or already voted, back to choosing on an error with the selection kept, and to closed if the poll closed. choosing and voted both go to closed when a live update says the poll closed.loadingchoosingsubmittingvotedclosedreadyVoteerror200or 409poll closedclosesalready overerror keeps choice
Most bugs hide on the edges, not in the boxes: the error edge back to choosing must keep the selection, and the close edge can fire while the reader is still choosing.

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?

Native radio buttons and checkboxes in a fieldset

A fieldset with a legend holding the question.

One input type=radio (or checkbox) with a label per option.

A separate Vote button submits the choice.

Pros

  • Keyboard behavior comes free: arrow keys move between radios, Space toggles checkboxes.
  • Screen readers announce "radio button, 2 of 4, not checked" without any ARIA.
  • Works before our script loads: a plain form can still submit.

Cons

  • Host CSS can restyle native inputs unless we isolate them.
  • One more tap than a one-click vote.

Custom div options with ARIA roles

div role=radiogroup with div role=radio aria-checked children.

We write the arrow-key handling and the roving tabindex ourselves.

Pros

  • Full control of the look.

Cons

  • We re-create what the browser already does, and the WAI-ARIA radio group pattern has many details to get wrong.
  • More code in a widget with a 15 KB budget.

One button per option, vote on click

Each option is a button. Clicking it casts the vote at once.

Popular in social apps for single-choice polls.

Pros

  • Fastest: one tap.
  • Simple semantics: buttons are well supported.

Cons

  • No chance to change your mind before submitting, so mis-taps on phones become votes.
  • Does not work for multiple choice.
  • A screen reader user hears a list of buttons, not "choose one of four".

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.

html
<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>
The voting view's markup: the browser provides the semantics.

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?

Script that renders into the host DOM

script async src="https://cdn.polls.example/embed.js" plus a placeholder div.

The script builds the poll as normal elements in the host's document.

Pros

  • Smallest possible setup.
  • Text is in the host's DOM, so the host's find-in-page and font sizes apply.
  • Height grows naturally; no sizing protocol.

Cons

  • No CSS isolation either way.
  • Our code runs with full access to the host page, which careful hosts refuse.
  • Host CSP must allow our script and our API in script-src and connect-src.
  • Our requests are third-party from the host's page.

Script that defines a web component with Shadow DOM

The script registers a custom element, say poll-widget.

The element attaches a shadow root and renders inside it.

Theming goes in through CSS custom properties, which do cross the shadow boundary.

Pros

  • Host selectors can't reach inside the shadow root, and ours can't leak out.
  • Still in the host's page: natural height, page zoom, and find-in-page work.
  • Familiar for hosts: poll-widget poll-id="p_81".

Cons

  • Inherited properties (font, color, line-height) still flow in from the host, so we reset them on :host.
  • Still runs our JavaScript in the host's page with full access, and host CSP still applies.
  • A crash or a heavy task still hurts the host's main thread.

Iframe served from our origin

The page loads https://embed.polls.example/p/p_81 in an iframe.

Our document has its own CSS, JavaScript, and security headers.

The iframe tells the parent its height with postMessage.

Pros

  • Full isolation of CSS, JavaScript, and errors. A crash stays in the frame.
  • Host CSP needs only frame-src; our own CSP governs our code.
  • The host page can't read or change votes in our frame.
  • Works with a plain iframe tag and no script at all, for the strictest hosts.

Cons

  • Height must be synced by message.
  • Each frame is a separate document: more memory, a separate request for its HTML.
  • Our storage and cookies inside it are third-party, so they are partitioned or blocked (chapter 5).
  • Theming must be passed in, since host CSS can't reach the frame.

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.

Embed as
Host page has
Embed lab. Embedded as script into host DOM. On the host page the poll shows: poll looks right and works. host CSS reaches in: yes, fully; our CSS leaks out: yes; strict host CSP: blocked; our crash: in host's page; height: grows by itself.news.exampleArticle headlinearticle text ...poll looks rightand worksmore article text ...host CSS reaches inyes, fullyour CSS leaks outyesstrict host CSPblockedour crashin host's pageheightgrows by itself
script into host DOM: poll looks right and works. Turn on the host problems to see each embed react.

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.

html
<!-- 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>
The two snippets. The placeholder reserves space so nothing jumps when the poll arrives.

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.

ts
// 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";
});
Both sides of the resize protocol.
Sequence diagram of loading the poll iframe and sizing it. Columns: loader on the host page, poll iframe, CDN, and another frame. The loader creates the iframe when the placeholder nears the viewport. The iframe requests the poll page from the CDN, which replies with HTML that already contains the poll. The iframe's ResizeObserver posts a resize message with height 412 to the loader, which checks origin and source and sets the iframe height to 412 pixels. Another frame posts a resize to 0; the loader ignores it because the origin is wrong.loaderpoll iframeCDNother framecreate iframeGET /p/p_81HTML with pollresize 412height: 412pxresize 0wrong origin: ignored
The loader trusts a message only if 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.

Storage partitioning for the poll iframe. Two browser windows: news.example and blog.example, each embedding the same poll iframe from embed.polls.example. Each frame has its own cookie and storage jar keyed by the top-level site: on news.example the voter id is v_1, on blog.example it is v_2. Our server receives votes from two voter ids and counts them as two readers.news.exampleembed.polls.examplejar: news + embedvoter_id = v_1blog.exampleembed.polls.examplejar: blog + embedvoter_id = v_2vote APIsees 2 voters
Same reader, same browser, same poll, two jars. That is why anonymous vote-once is promised per site, not per person.

Decision How do we recognize a returning anonymous voter?

Client flag only

After voting, write voted: p_81 -> a2 to localStorage in the frame.

On load, show results if the flag exists.

Pros

  • No server state; trivial to build.
  • Good for showing "Your vote" again on the next visit.

Cons

  • The server can't enforce anything.
  • Cleared by private windows, storage clearing, and other browsers.

Partitioned voter cookie plus server dedupe

The first vote response sets a random voter_id cookie: HttpOnly; Secure; SameSite=None; Partitioned. It is never set on the CDN-cached poll HTML, or every reader would share one id.

Each vote is stored with a unique key on (poll id, voter id); a second vote from the same id is rejected or treated as a change.

IP and network signals feed rate limits, not identity.

Pros

  • The server enforces the rule; scripts can't just loop.
  • HttpOnly keeps the id away from page JavaScript.
  • Partitioned (CHIPS, Cookies Having Independent Partitioned State) asks the browser to keep the cookie in a per-site jar, which is what lets it work where unpartitioned third-party cookies are blocked.

Cons

  • A new private window gets a new id.
  • Still per top-level site.
  • Safari only accepts partitioned cookies from version 26.2 (December 2025). Older Safari stores no third-party cookie at all, so each vote arrives with no id and only rate limits slow a repeat voter.

Host-verified identity

The host's backend signs a short token: { sub: "user_492", poll: "p_81", exp } with a secret from our dashboard.

The host page hands it to the frame by postMessage; the frame sends it with the vote.

Our server verifies the signature and dedupes on (poll id, host user id).

Pros

  • Real one-vote-per-account, across devices and browsers.
  • No third-party cookies needed at all.

Cons

  • Needs the host to have logins and a backend change.
  • Only as strong as the host's account signup (free accounts can still be farmed).

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?

Pessimistic: after the server confirms

Send the vote, show a spinner, render results from the response.

Pros

  • Never shows anything untrue.
  • Simplest code.

Cons

  • Up to seconds of spinner on slow networks.
  • Readers double-tap or leave.

Optimistic: at once, confirm in the background

Add the vote to the local counts and show results immediately.

Send the vote; on success, replace local counts with the server's; on failure, roll back and explain.

Pros

  • Results in under 100 ms.
  • Failures are rare, so the honest path is almost always the fast path.

Cons

  • Needs a clear rollback for each failure type.
  • Local counts and live updates must be merged carefully.

Optimistic with an offline queue

Like optimistic, but failed votes are stored and sent when the network returns, maybe hours later.

Pros

  • Votes survive a tunnel.

Cons

  • A vote sent hours later may land after the poll closed, or be counted on a different day than the reader expected.
  • Storage in a third-party frame is partitioned and may be cleared.

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.

Sequence diagram of an optimistic vote with a failure. Columns: reader, widget, store, vote API. The reader presses Vote. The widget adds a pending vote for a2 to the store, and the store re-renders results with plus one at once. The widget posts the vote with idempotency key K. The request times out. The widget retries with the same key K. The vote API replies 200 with snapshot version 5120. The widget confirms the vote in the store and drops the pending plus one.readerwidgetstorevote APIpress Voteadd pending a2render results +1POST vote, key Ktimeoutretry, same key K200, version 5120confirm, drop +1
The reader sees results at the third arrow, before the network is involved. The timeout is unknown, not failed, so the retry reuses key K and can't count twice. If all retries fail, the store rolls back to choosing with the selection kept.

Each failure, and what the reader sees

Server saysMeaningWhat the widget does
200 with resultsCounted.Replace local counts with the server's counts and version. Keep "Your vote".
409 already votedThis 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 closedClosed while the reader was choosing.Switch to the closed view with final results: "This poll closed before your vote arrived."
429 too many requestsRate limit hit.Roll back to choosing, keep the selection, show "Too many votes from your network. Try again in a minute."
Network error or 5xxUnknown: 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.

ts
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
}
What the widget shows: the newest server counts, plus the vote not yet inside them.

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.

ApproachLoad at 50,000 viewersDelay readers seeMain cost
Polling every 3 s, no cacheAbout 17,000 requests per second to our serversAbout 1.5 s on average, 3 s at worstOrigin load grows with every viewer.
Polling every 3 s, CDN cache of 2 sRoughly one request every 2 s per CDN location reaches usAbout 2.5 s on average, usually under 5 sSlightly staler data; nearly free at any scale.
SSE, snapshot once a second50,000 open connections; 50,000 small messages per secondAbout 1 sLong-lived connections and a fan-out tier.
WebSocket, snapshot once a secondSame as SSEAbout 1 sSame, plus a custom protocol, reconnect logic, and no HTTP caching.

Decision How should results reach readers who are watching?

Short polling a CDN-cached results URL

GET /v1/polls/p_81/results every 3 s, with Cache-Control: public, max-age=2, stale-while-revalidate=5.

Schedule each poll with setTimeout after the last answer; pause when hidden.

Pros

  • Plain HTTP: works through every proxy and corporate firewall.
  • The CDN absorbs the crowd: cost barely grows with viewers.
  • Stateless; deploys drop nothing.

Cons

  • Delay of a few seconds.
  • Each viewer still sends requests while watching.

Server-sent events

new EventSource("/v1/polls/p_81/stream"); the server writes a results event with each snapshot.

The browser reconnects by itself and sends Last-Event-ID.

Pros

  • About 1 s delay; no wasted requests.
  • One-way is exactly what results need; votes go by normal POST.
  • Built-in reconnect.

Cons

  • Holds a connection per viewer: needs a fan-out tier that scales.
  • Not cacheable by a normal CDN.

WebSocket

Open a socket, subscribe to the poll, receive snapshots, maybe send votes over it too.

Pros

  • Two-way and low latency.
  • Fits apps that already have a socket for chat or presence.

Cons

  • Two-way is not needed: readers send one vote, ever.
  • We write reconnect, heartbeat, and subscribe logic ourselves.
  • Bigger client code in a 15 KB budget.

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.

Data flow for votes and results. Write path: the frame store sends POST vote to the vote API, which adds one to a random counter shard. Read path: the aggregator sums the shards once a second into a versioned snapshot. The snapshot goes to the CDN cache, which frames read with GET results cached for 2 seconds, and for live-event polls to an SSE fan-out that pushes it to frames. The frame store merges it and the views render bars, percentages, and counts.vote APIdedupe, limitscounterssharded rowsframe storesnapshot + pendingCDN cacheresults, 2 saggregatorsum every 1 sviewsbars, %, countsSSE fan-outlive events onlyPOST vote+1readv5120GETlive pollseventrender
Votes go in one at a time; results come out as one small snapshot a second, the same bytes for every reader. That shape is what lets a CDN do the fan-out.

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 IntersectionObserver inside 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 visibilitychange to hidden, 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.

Votes
Rounding
Rounding lab, rounding each share, whole numbers. Tabs: 1 votes, exact 33.33%, shown 33%; Spaces: 1 votes, exact 33.33%, shown 33%; Both: 1 votes, exact 33.33%, shown 33%. Total shown: 99%.optionvotesbar (exact share)shownTabs133%Spaces133%Both133%total99%
Rounding each share on its own gives 99. Switch to largest remainder to fix the total without moving any share more than one step.
ts
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);
}
Largest remainder in integer math, so ties compare exactly. Tested in Node against 20,000 random polls: always 100, always within 1 point.
VotesExact sharesRound eachLargest remainder
1, 1, 133.33, 33.33, 33.3333, 33, 33 (99)34, 33, 33 (100)
2, 2, 2, 128.57, 28.57, 28.57, 14.2929, 29, 29, 14 (101)29, 29, 28, 14 (100)
1 each, 7 options14.29 each14 each (98)15, 15, then 14 five times (100)
1, 9990.1, 99.90, 100 (100)0, 100 (100), shown as below 1% and 100%
0, 0, 0undefinedNaN on screen0, 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) % total is exact. Floating-point remainders like 33.333333333333336 can 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.

Locale1204 votes58% share1,234,567 compactPlural categories
en-US1,20458%1.2Mone, other
de-DE1.20458 %1,2 Mio.one, other
fr-FR1 20458 %1,2 Mone, many, other (0 is "one")
ar-EGArabic-Indic digitsArabic percent signArabic word for millionzero, 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.

Architecture of the embeddable poll. The host page holds the placeholder and our 2 KB loader, which creates the iframe and talks to it by postMessage. Inside the iframe, from our origin: views, a bridge for messages, a formatter, the API client, the poll store, and the results source. The bridge feeds the store; the store drives the views; the store sends votes through the API client; the results source feeds snapshots into the store. Our servers: the vote API receives POST vote from the API client; the edge and CDN serve the frame HTML and cached results to the results source; the aggregator pushes snapshots by SSE for live polls.host pageplaceholderloaderiframe (our origin)viewsvote, resultsbridgechecks originsformatterIntl, roundingAPI clientkeys, retriespoll storestate + mergeresults srcpoll or SSEpostMessageour serversvote APIdedupe, limitsedge + CDNHTML, resultsaggregatorsnapshots, SSEPOST voteGET resultsSSE
Three zones, three trust levels. The host page runs only the loader; everything that renders or counts lives in our frame or on our servers, and the only path between host and frame is checked messages.
PartRuns inResponsibility
Loader (embed.js)Host pageFind placeholders, lazy-create iframes, apply heights from resize messages, pass lang, theme, and an optional identity token. Nothing else.
Frame shell (HTML)IframeServer-rendered poll: question, options, and results if visible, with critical CSS inline. Readable before any script runs.
Poll storeIframeThe state machine (loading to closed), the latest snapshot, the pending vote, and the merge rule.
ViewsIframeVoting, results, and closed views. Pure functions of the store.
API clientIframefetch calls with idempotency keys, timeouts through AbortController, and retries with backoff.
Results sourceIframeOne interface, two transports: CDN polling or EventSource. Pauses when hidden or off screen.
BridgeIframeAll postMessage traffic: resize out, identity and theme in, analytics events out. Checks origins.
FormatterIframeIntl number, percent, plural, and date formatting, plus roundShares.
Edge and CDNOur networkStatic assets with long cache, poll HTML with short cache, results JSON with a 2 s cache.
Vote APIOur serversValidate, rate-limit, dedupe, count, and return the new snapshot version.
Aggregator and streamOur serversSum sharded counters once a second into a versioned snapshot; publish it to the cache and to SSE subscribers.

The first load, step by step

  1. The host's HTML arrives with a placeholder that reserves 340px, and our async loader script (about 2 KB compressed, cached for a year under a versioned file name, behind a short-lived embed.js alias).
  2. The loader runs after the host's own work. An IntersectionObserver waits until the placeholder is within about 600px of the viewport.
  3. 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.
  4. 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.
  5. If the reader voted before, a localStorage flag 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).

ts
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";
};
The client-side types. Counts are raw; rounding happens at render time.
DataLives inLifetimeWhy there
Poll definitionServer; rendered into the frame HTML; CDN for 30 sUntil the author edits itSame for everyone, so cache it at the edge.
Results snapshotServer aggregator; CDN for 2 s; frame memoryAbout a secondSame for everyone; versioned so the frame can ignore old ones.
Voter idHttpOnly partitioned cookie on our originAbout a year, per top-level siteThe server needs it for dedupe; page scripts don't.
My vote (record of truth)Server, keyed by poll and voterForever, or as long as the pollOnly the server can enforce one vote.
My vote (hint)localStorage in the frame (partitioned)Until clearedShows "Your vote" instantly on return; never trusted.
Pending vote and attempt keyFrame memorySecondsNeeded only while the request is in flight.
UI stateFrame memoryThis page viewNobody else needs it.
Theme and localeFrame URL parametersThis page viewSet by the host; makes the HTML cacheable per variant.
Where the widget's data lives. Server and CDN: the Poll, with question, options, and mode, and the Snapshot, with version, counts, and closed. Frame memory: MyVote, with the pending vote and attempt key, and UiState, with phase and selection. A partitioned HttpOnly cookie holds the voter id. Partitioned localStorage holds a vote hint that shows Your vote fast but is never trusted.server + CDNPollquestion, options, modeSnapshotversion, counts, closedframe memoryMyVotepending, attempt keyUiStatephase, selectioncookie (partitioned)voter_idHttpOnly, server reads itlocalStorage (partitioned)vote hintfast, never trusted
Truth lives on the left (the server and the cookie it reads); speed lives on the right (memory and a hint). The snapshot's version is what joins the two when a pending vote meets a live update.

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 pathPurposeCaching
POST /v1/pollsDashboard creates a poll. Returns the id and the snippets.None; authenticated author.
GET /p/{id}?lang&themeThe frame's server-rendered HTML.CDN, max-age=30, varies by the query string.
GET /v1/polls/{id}/resultsThe latest snapshot as JSON. Sends ETag; answers 304 if unchanged.CDN, max-age=2, stale-while-revalidate=5.
GET /v1/polls/{id}/streamSSE: a results event per snapshot, for live-event polls.Not cached; fan-out tier.
POST /v1/polls/{id}/votesCast or change a vote. Needs Idempotency-Key.Never cached; reads the voter cookie.
GET /v1/polls/{id}/meThis voter's vote, if any.private, no-store.
http
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 } }
A vote and its answer. The response carries the snapshot version that first includes it.
json
{
  "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 }
  }
}
One error format for every failure, so the widget maps codes to messages without parsing text.

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

text
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
Snapshots only, at most one a second. The id lets a reconnecting browser resume.

The embed contract: attributes and messages

NameDirectionShapeRule
data-pollSnippet to loaderPoll idRequired.
data-theme, data-accent, data-radiusSnippet to loader to frame URLlight, dark, auto; a hex color; a pixel valueValidated; unknown values ignored.
pollx:readyFrame to host{ v: 1, pollId }Sent once the frame can receive messages.
pollx:resizeFrame to host{ v: 1, height }Host checks origin and source; clamps the value.
pollx:identityHost to frame{ v: 1, token }Frame accepts only from the poll's allowed origins.
pollx:eventFrame 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?

Rate limits only

Limits per voter id, per IP block, and per poll, enforced at the edge.

Example: 5 votes a minute per voter id across polls; 300 a minute per IPv4 address or IPv6 /64 per poll.

Pros

  • Invisible to honest readers.
  • Cheap; stops naive loops.

Cons

  • Offices, schools, and mobile carriers share IPs, so limits must be generous.
  • A botnet with many IPs walks past them.

A challenge on every vote

A CAPTCHA or interactive check before each vote.

Pros

  • Stops most simple scripts.

Cons

  • Big drop in honest votes.
  • Accessibility problems with visual puzzles.
  • Large third-party script in our frame.

Rate limits, plus a challenge only when suspicious

Score each vote with server signals: rate, network type (data center or residential), sudden spikes on one poll.

Low score: count it. Medium: ask for an invisible or one-click challenge. High: accept the request but hold the vote for review.

Pros

  • Honest readers almost never see a challenge.
  • Held votes can be reviewed and removed in bulk, with a visible note for the author.

Cons

  • More server logic and tuning.
  • A held vote looks counted to the voter, which must be stated in the poll's terms.

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=auto uses prefers-color-scheme inside the frame. But the host may be dark while the reader's system is light, so hosts that know their own theme should pass light or dark. Set the CSS color-scheme property 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

PieceBudget (compressed)When it loads
Loader embed.js2 KBasync, after the host's HTML; cached for a year under a versioned name.
Frame HTML with inline critical CSS6 KBWhen the poll nears the viewport.
Frame script (store, views, API client, formatter)8 KBdefer in the frame; the poll is already visible.
SSE or challenge code3 to 15 KBOnly for live-event polls or suspicious traffic, by dynamic import().
Load timeline on the host page, from 0 to 3.5 seconds. The host's HTML and hero image finish first, with Largest Contentful Paint at 1.2 seconds. Our 2 KB async loader runs around 1.3 to 1.7 seconds without blocking. The reader scrolls toward the poll between about 1.6 and 2.6 seconds. Only then the frame HTML loads, the poll becomes visible at about 3 seconds, and the 8 KB script attaches after it is already visible.hosthost contentloader2 KBreaderscrolls nearframeHTML8 KBLCP 1.2 s0 s1 s2 s3 s
Nothing of ours competes with the host's LCP. The frame is created only when the reader nears it, and the poll is readable before its script runs.
  • LCP: our loader is async and 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-height makes 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 animating width. Under prefers-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.

MinutesWhat to doWhat to say out loud
0 to 5Ask 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 8Write 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 18Embedding 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 26Vote-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 33Architecture diagram, then the optimistic vote flow with its failure cases."A timeout is unknown, not failed, so the retry reuses the key."
33 to 40Live 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 45Accessibility, 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?

Think first

Product wants readers to be able to change their vote. What changes in the design?

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?

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 postMessage and check event.origin and event.source on 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 HttpOnly voter 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 Intl for 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next