Frontend System Design: Star Rating Widget

interviewAugust 31, 2026ยท 7 min read

"Design a star rating widget" sounds like a warm-up question. Five stars, click one, done โ€” twenty minutes of an interview. That's exactly the trap. The basic version is a machine-coding exercise; the production version is what Amazon actually ships: an average rounded to a tenth, a distribution histogram, your own rating editable after the fact, half-star hover precision, no layout shift on load, and correct behaviour when you're offline or the network is slow.

This article walks through the full design using the RADIO framework: Requirements, Architecture, Data Model, Interface, Optimisations.

R โ€” Requirements

Clarify scope first โ€” the interviewer is testing whether you know this is more than five icons.

Functional:

  • Display the aggregate: average rating (one decimal) and total count, e.g. "4.3 โ˜… ยท 12,847 ratings"
  • Display the distribution: what fraction gave 1โ˜… vs 5โ˜… (the histogram bars)
  • Let a signed-in user set, edit, or clear their rating โ€” exactly one rating per user per item
  • Support half-star precision on input (hover left half of the third star = 2.5)
  • Re-render the aggregate after a submission without a full page reload

Non-functional:

  • Instant interaction โ€” star fill on hover/click must be same-frame, no network round-trip
  • No layout shift โ€” the widget reserves its final size before data arrives
  • Accessible: keyboard and screen-reader users can read and set ratings
  • International: works in RTL languages, comma-vs-dot decimal separators
  • Eventually consistent โ€” a submitted rating updates the aggregate the user sees, even if the backend recomputes asynchronously

Good clarifying questions: Is rating anonymous or authenticated? Can users rate without buying? Do we need verified-purchase weighting? These signal senior thinking more than any line of code.

A โ€” Architecture

Three components and one state container, each independently testable:

RatingSection
โ”œโ”€โ”€ RatingProvider      (state + server sync, no UI)
โ”œโ”€โ”€ RatingSummary       (average, count, histogram โ€” read-only)
โ””โ”€โ”€ RatingInput         (interactive stars โ€” write path)

RatingProvider owns the client cache: { average, count, histogram, myRating, status }. Both children subscribe to it. Input and summary never talk to each other directly โ€” a change flows through the provider, which handles the server call and the optimistic update.

Rendering partial stars is the architecture-level rendering decision. A 4.3 average is not four stars โ€” it's 43% of a fifth star. The proven technique is the two-layer overlay:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ โ˜†  โ˜†  โ˜†  โ˜†  โ˜†   โ† empty layer (grey)
โ”‚ โ˜…  โ˜…  โ˜…  โ˜…  โ–“โ–‘โ–‘โ–‘โ–‘  โ† filled layer, clipped to 86% width
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Render the grey stars once, absolutely position an identical row of filled stars on top, and set its clip-path: inset(0 14% 0 0) (or width) to (average / max) * 100%. Per-star conditional fills cannot express 4.3 โ€” this is the detail interviewers wait for.

Input uses the same geometry. On pointer move over the input row, map the cursor's x-position within a star's bounding box to a value: left half โ†’ n - 0.5, right half โ†’ n. One Math.round(x * 2) / 2 quantises to halves. On touch devices, skip hover entirely โ€” taps go straight to full-star targets, with half-star via a long-press or a larger hit analysis; mobile users overwhelmingly mean whole stars.

D โ€” Data Model

Server โ€” two tables:

-- one row per user per item (the source of truth)
ratings (
  user_id, item_id, value NUMERIC(2,1), updated_at,
  UNIQUE (user_id, item_id)          -- enforces "one rating per user"
)

-- precomputed aggregate, one row per item (the read model)
rating_aggregates (
  item_id PRIMARY KEY,
  histogram INT[5],                  -- [count_1star, ..., count_5star]
  updated_at
)

Why store the histogram array instead of SUM(value) and COUNT(*)? Because the summary UI needs the distribution, and AVG alone can't render it. From the histogram you can derive average, count, and every histogram bar without touching the ratings table.

Updates are transactional: upsert the rating row, then atomically adjust the histogram โ€” if the user changed 4โ˜… to 5โ˜…, that's histogram[3] -= 1; histogram[4] += 1. Reads hit only rating_aggregates (cacheable, O(1)). At Amazon scale the write path goes through a queue and the aggregate is recomputed asynchronously โ€” the user's own view is patched optimistically (below) so nothing feels slow.

Client cache shape:

{
  average: 4.3, count: 12847,
  histogram: [312, 589, 1892, 4521, 5533],
  myRating: null,          // number | null | undefined = "still loading"
  status: "idle" | "saving" | "saved" | "error",
}

myRating: undefined (loading) vs null (not rated) is a real distinction โ€” rendering the input as empty before you know risks a flash of "you haven't rated" followed by "you rated 4โ˜…".

I โ€” Interface

Server API:

GET    /api/items/:id/rating        โ†’ { average, count, histogram, myRating }
PUT    /api/items/:id/rating       body { value: 4.5 }   (upsert, idempotent)
DELETE /api/items/:id/rating                              (clear my rating)

PUT is an upsert โ€” calling it twice with 4.5 must not double-count. Idempotency here comes from the UNIQUE constraint plus the histogram delta logic, not from client-side guards.

Component interface (React-shaped, but the pattern is framework-agnostic):

<RatingInput
  value={myRating}          // controlled
  onChange={setValue}       // optimistic handler
  precision={0.5}
  max={5}
  readOnly={false}
  size="md"
  label="Rate this product"
/>

Support controlled and uncontrolled modes (default defaultValue + internal state) โ€” library authors ask for this; interviewers notice when you mention it unprompted.

O โ€” Optimisations

1. Optimistic UI with a debounce

Star taps should feel instant: fill the star immediately in local state, then talk to the server. Debounce the PUT by ~500ms โ€” users on mobile tap stars repeatedly while deciding, and you don't want five API calls. Then patch the local aggregate optimistically:

function applyMyRating(prev, newRating) {
  const old = prev.myRating
  const h = [...prev.histogram]
  if (old) h[old - 1] -= 1          // remove old contribution
  h[newRating - 1] += 1             // add new contribution
  const count = prev.count + (old ? 0 : 1)
  const sum = h.reduce((s, n, i) => s + n * (i + 1), 0)
  return { ...prev, histogram: h, count,
           average: +(sum / count).toFixed(1), myRating: newRating }
}

On error, roll back to the server value and surface a toast โ€” never leave the widget silently lying. Queue the retry (with the user's auth token) rather than dropping it.

2. The low-count trap: 5.0 โ˜… from two ratings

An item with one five-star rating shows "5.0 โ˜…" and outranks an item with 4.8 โ˜… from 3,000 ratings. The senior answer: display the count next to the average, and sort/rank with a Bayesian prior โ€” IMDb's weighted rating pulls items with few votes toward the global mean. Even if you never implement it, saying "raw average is misleading at low counts; the display should stay honest and ranking should be smoothed" is a differentiator.

3. Accessibility โ€” this is where tutorials fail

The input is an ARIA radiogroup of five radios: arrow keys move between stars (Up/Right increase), Home/End jump to 1/5, Enter or Space commits. Each radio announces a real label ("3 stars"), and the group announces the current state ("Rated 4 of 5 stars. 4.3 average from 12,847 ratings."). The hover-preview layer is pointer-only decoration โ€” it must be aria-hidden, because a screen reader should never hear "2.5" while a keyboard user can only select 3 or... actually, with precision={0.5} you expose ten stops and announce halves. Decide, state the trade-off, implement consistently.

4. RTL, i18n, and decimals

In RTL layouts the star row mirrors โ€” low stars on the right. Build with logical properties (inset-inline-start) and let the direction do the work; never hardcode left. Format the average with Intl.NumberFormat โ€” "4,3" in Germany is not a rendering bug.

5. SSR, hydration, and layout shift

The summary (average, count, histogram) renders server-side for SEO โ€” crawlers see the rating in the initial HTML. Two follow-up problems:

  • Hydration mismatch: if the user's own rating lives in localStorage (or the session differs), the server HTML won't match the client render. Read client-only state in an effect after mount, not during initial render.
  • Layout shift: reserve the widget's dimensions with a skeleton of identical height, so a lazy-loaded rating doesn't shove the buy button down the page (a Core Web Vitals hit).

6. Spam and trust

One rating per user per item (the UNIQUE constraint), rate-limit the PUT, and optionally weight verified purchases. If fraud matters, mention anomaly detection (burst ratings from new accounts) โ€” as a system boundary, not something you build client-side.

Interview Gotchas

The questions that separate levels:

  • "How do you show 4.3 stars?" โ†’ two-layer clip overlay. If the candidate starts writing five conditionals, that's the tutorial answer.
  • "User taps stars five times fast." โ†’ optimistic local fill + debounced idempotent upsert.
  • "How does the histogram update?" โ†’ client-side delta patch optimistically; server recomputes the aggregate asynchronously from the source of truth.
  • "Average of what โ€” two ratings or two thousand?" โ†’ count next to average, Bayesian smoothing for ranking.
  • "Offline?" โ†’ queue the mutation in IndexedDB/localStorage, retry on reconnect, and keep status: "saving" honest.
  • "Keyboard-only user?" โ†’ radiogroup, arrow keys, labels. If they don't bring up a11y unprompted, the interviewer will.

Summary

A production-grade star rating design:

  • Requirements: average + count + histogram display, one editable rating per user, half-star precision, instant feel, a11y, i18n, SEO
  • Architecture: RatingProvider (state/sync) + RatingSummary (read) + RatingInput (write); partial stars via two-layer clip overlay, never per-star conditionals
  • Data model: ratings (source of truth, UNIQUE per user/item) + rating_aggregates histogram array as the O(1) read model
  • Interface: idempotent PUT upsert; controlled/uncontrolled input component
  • Optimisations: optimistic patch with rollback, debounced submission, Bayesian-aware display, ARIA radiogroup, RTL/logical properties, SSR with post-mount hydration of personal state, zero layout shift

The widget is small; the surface area is not. That's why it's asked.

The Series: Frontend System Design Interview Questions

Donate
ยฉ 2024, Built with Gatsby