cf.completefrontend
My progress
SYSTEM DESIGN · #53

Design a recently viewed products feature

The row of products a shopper looked at lately, like Amazon's browsing history or Flipkart's Recently viewed. Showing a list is easy; the real work is deciding what counts as a view, where the list lives for guests and signed-in users, merging the two on login, keeping tabs in step, refreshing stale prices, and giving people control over their own history.

The question

Design the recently viewed products feature for a large online store. Every product page and the home page show a row of the last products this shopper looked at. Guests and signed-in shoppers both get it, and signed-in shoppers should see the same list on their phone and laptop.

Walk me through what counts as a view, where you store the list, what happens when a guest signs in or someone signs out on a shared computer, how the rail stays correct across tabs, and how you keep it fast and private.

Real-life examples: Amazon Your browsing history, Amazon Inspired by your browsing history, Flipkart Recently viewed, Booking.com recently viewed hotels, YouTube watch history

Requirements: a small list with big edges

A recently viewed feature remembers which products one shopper opened and shows them again, newest first, usually as a horizontal row of cards called a rail. Amazon shows it as Your browsing history, Flipkart as Recently viewed, Booking.com as the hotels you looked at. People compare: they open five pairs of headphones, wander off, and want to get back to the second one without searching again.

It sounds like ten lines of code: push the product id into an array in localStorage, read it back, draw cards. That version ships in an afternoon, and then the bug reports arrive.

The same jacket shows three times, once per colour. The rail fills with products the shopper never opened, because the browser prerendered them. A guest signs in and their history vanishes, and after a sign-out on a library computer the next person sees it. A card says 49 dollars while the page says 65: each of these is a design decision, taken one at a time below.

Questions to ask first

QuestionAnswer this guide assumesWhy it changes the design
Who gets the feature: guests, signed-in users, or both?Both. Most visitors to a store are guests.Guests force a client-side store; signed-in users want the server copy. Both means a merge on login.
Must the list follow a signed-in user across devices?Yes, phone and laptop show the same list.A browser-only list can't do this. The server must own the list for accounts.
Is the history used for recommendations?Yes, the server uses it, but that ranking is out of scope here.The server needs the views of signed-in users anyway, which supports storing them there.
How many items does the rail show, and how many do we keep?Show up to 20, keep up to 50 (estimate).Sets the size of the stored list and when eviction happens.
Do shoppers need to manage it?Yes: remove one item, clear all, pause.Adds a Manage history page and a paused flag that every writer must respect.
Which regions?Includes the EU and the UK.Storing a history to personalise the page likely needs consent there.

Functional requirements

  • Record a view when a shopper really looks at a product page, not when a link is hovered, prefetched, or crawled.
  • Show a rail of recent products, newest first, with no duplicates, on product pages (excluding the current product) and on the home page.
  • Treat colour and size variants of one product as one entry.
  • Keep the list for guests on this browser, and for signed-in users on every device. Merge the guest list into the account on sign-in.
  • Let shoppers remove one item, clear everything, and pause recording.
  • Show the current price and stock, and hide products that were deleted.

Non-functional requirements

  • Never slow the product page. Recording a view must add 0 ms to the page's Largest Contentful Paint and must not block input. It runs after the page is idle.
  • No layout shift. The rail is filled in after the page loads, so its space is reserved: Cumulative Layout Shift from the rail stays at 0.
  • Small. The stored list fits in a few kilobytes, and the rail's code is lazy loaded, about 5 to 8 KB gzipped (estimate).
  • Consistent across tabs within a second, and across devices on the next page load.
  • Private by default on shared computers, and respectful of consent and pause.
  • Accessible: a labelled list that works with a keyboard and a screen reader.

Out of scope

The product page and recommendations are in the Amazon guide, and guest cart merging and cart storage are in the shopping cart guide; this guide reuses that thinking without repeating it. The carousel component itself (snapping, swiping, slide counts) is in the image carousel guide. Consent categories are in the cookie consent guide, and sending view events to analytics is the analytics SDK guide's job. A general sync engine with queues and conflict handling is in the offline sync engine guide; a list this small does not need one.

What makes this hard

Five properties of the problem drive every later decision. Name them early in an interview: it shows you see past the array.

  1. The input is noisy. Page loads are not views. Browsers prefetch and prerender pages a shopper never sees, crawlers load thousands of pages, and people open a tab and close it in half a second. The list is only useful if it records intent.
  2. One person, two identities. A shopper browses as a guest, then signs in. Their history lives in two places that must become one list, without duplicates, and without leaking into the next person's session on a shared computer.
  3. Many writers, one list. Three open tabs, a phone, and a laptop all add to the same history. Two tabs that each read, push, and write the list can overwrite each other.
  4. The stored copy goes stale. A product viewed last week may now cost more, be out of stock, or be deleted. A rail that shows old prices is worse than no rail.
  5. It is personal data. Browsing history says a lot about a person: a pregnancy test, a wheelchair, a gift for a partner. Shoppers need to see it, delete it, and turn it off, and in the EU and the UK storing it likely needs consent.

Notice what is not hard: the volume. One shopper's list is a few dozen entries and a few kilobytes, so the rail needs no virtualization or pagination. The difficulty is in correctness, identity, and privacy, which is why an easy problem still separates candidates well.

Wireframe of a product page for a red trail jacket at 65 dollars with an Add to cart button. Below the fold is a section with the heading Recently viewed, two scroll buttons on the right, and a row of cards: Boots, which has keyboard focus, Tent, Lamp, Stove, and the edge of a fifth card cut off. Notes: the rail is a list of links with one tab stop each and a reserved height; the scroll buttons call scrollBy; the jacket on this page is left out of its own rail.store.example/p/trail-jacketproduct imageTrail jacket, red$65.00Add to cartbelow the foldh2: Recently viewed‹›BootsTentLampStoveul of links, one tab stop eachreserved height, no shift‹ › buttons call scrollBythis product left out
The rail is a plain list below the fold. Its height is reserved before the cards arrive, the current product is left out, and the cut-off card at the right edge tells the shopper the row scrolls.

Check your understanding · Exercise 1

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

What counts as a view

The naive rule records a view on every product page load, and speculative loading breaks it. Many stores use Speculation Rules to prerender a product page when the shopper rests the pointer on its link, so the next click feels instant (the prefetching lesson explains the API). A shopper hovers 6 cards on a category page and clicks one: the rail now shows 6 products, 5 of which they never opened.

Now add a shopper who opens 10 search results in new tabs, glances at each for one second, and closes 9. The rail is full of rejects, and the one they liked is buried.

Decision When should a product count as viewed?

Any page load

Record in the page's startup code, or on the server when the product HTML is requested.

Pros

  • Trivial to build.
  • Works with JavaScript off if done on the server.

Cons

  • Counts prefetches, prerenders, crawlers, and tabs opened in the background and never looked at.
  • Fills the rail with noise.

Visible for N seconds

Start a timer only while the page is visible to the user; record when it reaches N seconds (say 3, an estimate to tune).

Pause the timer when the tab is hidden.

Pros

  • Ignores prerenders and background tabs for free, since they are not visible.
  • Filters out quick bounces.

Cons

  • A shopper who reads the title in 2 seconds and leaves is not counted.
  • Needs a small amount of client code.

Visible for N seconds or an engagement signal

Same timer, but also record at once on a strong signal: scrolling past the first screen, opening the image gallery, choosing a size.

Pros

  • Catches fast but real interest.
  • Signals are cheap to listen for.

Cons

  • Slightly more code and more rules to explain to the team.

What we pick: Pick visible for N seconds or an engagement signal, with N around 3 seconds as a starting point to tune with data. Visibility is the key word: a prerendered or background page is not visible, so it can never record a view, and the time threshold filters the open and close bounces.

Prefetch and prerender must not count

There are two kinds of speculative loads, and they need different guards. A prefetch only downloads the HTML; no script runs, so client-side recording is safe by default. But if the server records views when it serves the product HTML, it must check the request header. Chrome and Firefox send Sec-Purpose: prefetch on prefetch requests, and Chrome sends Sec-Purpose: prefetch;prerender for prerenders.

A prerender goes further: the page is loaded and its scripts run in a hidden tab. While that happens, document.prerendering is true. When the shopper actually navigates to it, the prerenderingchange event fires, and only then should the timer start. Chrome's prerender documentation gives the same advice for analytics: wait for activation.

ts
const DWELL_MS = 3000; // estimate, tune with data

export function watchView(productId: string, record: (id: string) => void) {
  let shown = 0;          // ms visible so far
  let since = 0;          // when the current visible stretch began
  let timer = 0;
  let done = false;

  const fire = () => { if (!done) { done = true; record(productId); stop(); } };
  const tick = () => {
    if (document.visibilityState === "visible") {
      since = performance.now();
      timer = window.setTimeout(fire, DWELL_MS - shown);
    } else if (since) {
      shown += performance.now() - since;
      since = 0;
      clearTimeout(timer);
    }
  };
  const stop = () => document.removeEventListener("visibilitychange", tick);

  const begin = () => { document.addEventListener("visibilitychange", tick); tick(); };
  if (document.prerendering) {
    document.addEventListener("prerenderingchange", begin, { once: true });
  } else {
    begin();
  }
  return { engaged: fire, cancel: () => { done = true; clearTimeout(timer); stop(); } };
}
Start counting only when the page is really in front of the shopper.

The returned engaged function is what the gallery, the size picker, and a scroll listener call to record at once. cancel runs when a single page app navigates away before the timer fires. In a single page app, also make sure the watcher starts on client-side route changes, not only on the first load.

State machine for the view watcher on one product page. A prerendering page has no timer; when it is activated it moves to counting, where the page is visible and the timer runs. After 3 seconds or an engagement signal it moves to recorded, and the product is added to the list. If the tab is hidden, counting moves to hidden, where the timer stops; when the tab is visible again it goes back to counting. If the page is closed or left while hidden, it moves to dropped. A note says that when history is paused or consent is missing the watcher never starts.activated3 s or engagedprerenderingno timercountingvisible, timer onrecordedadded to listhiddentimer stoppeddroppedleft before 3 stab hiddenvisible againclosedhistory paused orno consent:never starts
Only the counting state can reach recorded, and only a visible page can count. That one rule ignores prerenders, background tabs, and quick bounces. The engaged call jumps straight from counting to recorded.

Bots and crawlers

Crawlers have no account and no stored list that anyone reads, so client-side recording can ignore them. The risk is on the server: if signed-in views are written by an endpoint, a scripted client could fill an account's history. Rate limit the endpoint per account (for example 1 view per product per minute, and a few hundred per day: estimates) and ignore views from sessions flagged as automated by the bot detection the store already runs.

Same product twice, and variants

A history list is a set ordered by recency, not a log. Viewing a product that is already in the list does not add a second entry: it moves the existing entry to the front and updates its viewedAt. This is exactly the least recently used (LRU) order: the front is the newest, the back is the first to go when the list is full.

Variants need one decision: what is the key? A jacket in red and in blue usually has two SKU ids (stock keeping units) but one parent product id. The shopper thinks of it as one jacket, so key the list by the parent id and keep the last viewed variant, so the card shows the colour picked last. A store whose variants differ a lot in price (a phone with 128 GB or 512 GB) may choose differently, but the default is one entry per parent.

Check your understanding · Exercise 2

A shopper hovers a product card, the category page prerenders that product, and the shopper moves on without clicking. Which statements are true? Select all that apply.

Where the history lives, and what each entry holds

The naive store is an array of ids in localStorage. For a guest on one browser that is close to right. It breaks the moment the requirements say a signed-in shopper sees the same list on phone and laptop: localStorage belongs to one browser profile on one device, and the server never sees it.

The opposite naive store, server only, breaks for guests. Most visits to a store come from people who are not signed in, so there is no account to attach the list to. The workaround is an anonymous server profile for every visitor, which costs a server write per view for millions of one-time visitors.

Decision Where should the list be stored?

localStorage

One key, such as rv:v1, holding a JSON array of entries.

Read synchronously on load, written after each view.

Pros

  • Simple, synchronous, available everywhere.
  • About 5 MiB per origin, far more than a 50 item list needs (about 10 KB).
  • Fires the storage event in other tabs, which gives cross-tab sync for free.

Cons

  • Synchronous reads and writes run on the main thread; fine for 10 KB, bad for megabytes.
  • One device only.
  • Can throw QuotaExceededError, and may be unavailable or short-lived in private browsing.

IndexedDB

A views object store keyed by product id, with an index on viewedAt.

Pros

  • Asynchronous, so it never blocks the main thread.
  • Handles large data and structured records; good if you also cache product snapshots.

Cons

  • Much more code for a 10 KB list.
  • No built-in change event across tabs; you add a BroadcastChannel.
  • Still one device only.

First-party cookie

A cookie like rv=123,456,789 that the server reads to render the rail.

Pros

  • The server sees it on the first request, so it could render the rail in the HTML.

Cons

  • Browsers only guarantee about 4 KB per cookie: a few hundred short ids, fewer with timestamps.
  • Sent with every request to the domain, including images and API calls: 2 KB on each of 80 requests is 160 KB of upload per page for no reason.
  • Makes every page response vary by cookie, which hurts shared caching.

Server per account

POST /me/recently-viewed on each view; GET returns the list.

Pros

  • Follows the shopper across devices.
  • The server already wants the data for recommendations.
  • Survives browser data being cleared.

Cons

  • Needs an account, or an anonymous server profile for every guest.
  • A network call per view; must be batched and must not slow the page.

What we pick: Use both, split by identity: localStorage for guests, the server for signed-in shoppers, with the local list acting as a fast cache of the server list while signed in. IndexedDB is not worth its extra code for a list this small; switch to it only if you also cache product snapshots in bulk. The cookie's only advantage is server rendering, which this design does not need: the rendering chapter explains why the rail is drawn in the browser.

The client storage lesson compares these stores in depth. The shopping cart guide made the same guest versus account split, but the stakes differ: a lost cart line loses a sale, while a lost history entry is a small annoyance. So history can use the simpler last writer wins by timestamp, described in the next chapter, where the cart needed careful conflict handling.

How the hybrid behaves

SituationRead fromWrite to
GuestlocalStoragelocalStorage only.
Signed in, page loadlocalStorage first (instant), then the server list replaces itBoth: local at once, server in a batched beacon.
Signing inMerge local into server, then use the merged listServer, then overwrite local with the result.
Signing outNothingClear the local list.
History pausedThe existing list, read onlyNothing.
No consent yet (EU, UK)Nothing: no railNothing.

The shape of an entry

Each entry needs the product id and when it was viewed. The open question is whether to also store a snapshot: the title, price, and image URL at the moment of viewing, so the rail can draw cards without a network call.

Decision Store a snapshot of the product, or only its id?

Ids only

Store id, variant, and time. On render, fetch current details for the ids in one batched call.

Pros

  • Tiny: about 60 bytes per entry.
  • Always shows the current price and stock.

Cons

  • The rail can't draw anything until the fetch returns: skeletons for 100 to 300 ms (estimate).

Full snapshot, trusted

Store title, price, image, and stock with each entry and render straight from it.

Pros

  • Instant rail, works offline.

Cons

  • Shows stale prices and sold-out items as available: a card saying 49 dollars that opens a page saying 65 erodes trust.

Small snapshot, then refresh

Store title and image URL for an instant first paint, but never the price. Fetch price, stock, and existence for all ids and patch the cards.

Pros

  • Cards appear at once with real titles and images.
  • Price and stock are always fresh; deleted products drop out.

Cons

  • About 200 bytes per entry instead of 60.
  • Two render passes, so the price area needs a reserved slot.

What we pick: Store a small snapshot without the price, and always refresh price, stock, and existence from the server before showing them. Titles and images change rarely and are harmless if a day old; prices and stock are not.

ts
type ViewEntry = {
  productId: string;   // parent product, the dedupe key
  variantId?: string;  // last variant looked at, for the card image
  viewedAt: number;    // ms since epoch, the LRU order
  title: string;       // snapshot for instant paint
  imageUrl: string;    // snapshot for instant paint
};

type StoredHistory = {
  v: 1;
  paused: boolean;
  entries: ViewEntry[];  // newest first, at most MAX_ITEMS
};

// Fresh details, fetched each time the rail renders.
type ProductSummary = {
  productId: string;
  price: { amount: number; currency: string };
  inStock: boolean;
  available: boolean;  // false if deleted or blocked in this region
};
The stored list. The version lets a future release change the shape safely.
Data model. Left, the localStorage key rv:v1, about 10 KB, with version 1, paused false, and entries newest first: p42 viewed at 10:07, Jacket; p17 at 10:05, Boots; p88 at 09:12, Tent; up to 50 entries, and no price stored. The ids go right to GET products summary, which answers p42 costs 65 dollars and is in stock, p17 costs 40 dollars and is sold out, and p88 was deleted. Notes: fresh on every render, drop deleted ids.localStorage rv:v1v: 1, paused: falsep42 · 10:07 · Jacketp17 · 10:05 · Bootsp88 · 09:12 · Tentup to 50, newest firstno price storedGET /products/summaryp42 · $65 · in stockp17 · $40 · sold outp88 · deletedfresh on every renderdrop deleted idsids
The stored entry is enough to paint a card at once: id, time, title, image. Everything that can change and matters to a buyer, the price and the stock, comes from the server each time, and a deleted product is dropped from the list.

How many to keep, and for how long

Keep at most 50 entries (an estimate: the rail shows about 20, and the Manage history page can show more). With a 200 byte entry, 50 entries is about 10 KB of JSON, well inside the localStorage quota and quick to parse. Expire entries older than 90 days (also an estimate to agree with the product team): an old view is weak intent, and keeping less data is a privacy win. The server may keep account history longer, under its own retention policy.

Adding a view is one pure function: drop any old entry with the same product id, put the new one at the front, drop expired entries, and cut the list at the cap. Because the list is kept newest first, cutting the tail is the LRU eviction.

ts
const MAX_ITEMS = 50;                  // estimate
const MAX_AGE_MS = 90 * 24 * 3600_000; // 90 days, estimate

export function addView(list: ViewEntry[], entry: ViewEntry, now = Date.now()) {
  return [entry, ...list.filter((e) => e.productId !== entry.productId)]
    .filter((e) => now - e.viewedAt < MAX_AGE_MS)
    .slice(0, MAX_ITEMS);
}

Try it in the lab below: view products, view one twice, pick a variant, trigger a prerender, and watch what the stored list does when it reaches the cap (5 in the lab, so you can reach it quickly). Then sign in to see the merge, which the next chapter explains, and sign out as if on a shared computer.

Open
Account
This browser's list: empty. Account list on the server: Boots at 10:03, Socks at 9:40, Hat at 9:20. Guest.This browser, localStorage (cap 5)Account on the server: not linked yetBoots10:03Socks9:40Hat9:20
A guest on a laptop. Open products and watch the stored list.

A guest on a laptop. Open products and watch the stored list.

Think first

The refresh call says 3 of the 20 products in the rail are no longer available. What should the rail do, and should the stored list change?

Check your understanding · Exercise 3

Why is a cookie a poor place for the history list? Select all that apply.

Signing in and signing out

A guest views 8 products, then signs in to an account that already has 30 entries from their phone. The naive approaches both lose. Keep the server list and the 8 guest views vanish; keep the local list and the phone's 30 vanish. Append one to the other and products the shopper viewed on both devices appear twice.

Decision How do the guest list and the account list become one?

Server wins

Discard the local list on sign-in and load the server list.

Pros

  • No merge code.

Cons

  • Loses what the shopper just did, which is the most relevant part.

Client merges, then writes the whole list

Fetch the server list, merge in the browser, PUT the result.

Pros

  • Server stays a dumb store.

Cons

  • Two tabs signing in at once, or the phone adding a view between the fetch and the PUT, overwrite each other.

Server merges a union

Client sends its local entries to POST /me/recently-viewed/merge; the server unions by product id, keeps the latest viewedAt, sorts, caps, and returns the result.

Pros

  • One atomic step on the server, no lost updates.
  • Idempotent: sending the same entries twice gives the same list.

Cons

  • A slightly bigger endpoint.

What we pick: Let the server merge with a union by product id, keeping the newest viewedAt, then sort and cap. It is a tiny case of the conflict resolution lesson: because every entry is just a time per key, last writer wins by timestamp is correct and needs no user input.

ts
export function mergeHistories(a: ViewEntry[], b: ViewEntry[], now = Date.now()) {
  const byId = new Map<string, ViewEntry>();
  for (const e of [...a, ...b]) {
    const seen = byId.get(e.productId);
    if (!seen || e.viewedAt > seen.viewedAt) byId.set(e.productId, e);
  }
  return [...byId.values()]
    .filter((e) => now - e.viewedAt < MAX_AGE_MS)
    .sort((x, y) => y.viewedAt - x.viewedAt)
    .slice(0, MAX_ITEMS);
}
The merge rule, the same on server and client. Running it twice changes nothing.

Idempotent means doing it twice has the same effect as doing it once. That matters because sign-in flows retry: the merge request can time out after the server applied it, and the client sends it again. With a union by id, the second call finds every entry already present with an equal or newer time and changes nothing.

One caution on clocks: viewedAt comes from the device, and a phone whose clock is 10 minutes fast will win comparisons it shouldn't. The server should clamp times in the future to its own now. For a history list, the small error that remains is acceptable.

Sequence for signing in and out. The user signs in. The browser posts to the Auth API, which replies 200 with a session. The browser posts the 8 guest entries to the History API merge endpoint. On the failure path the request times out with a 504. The browser retries the same merge. The History API replies 200 with the merged list of 36 entries, since 2 of the 8 guest products were already among the account's 30. The browser saves it locally and re-renders the rail for the user. Later the user signs out, and the browser clears the local list.UserBrowserAuth APIHistory APIsign inPOST /login200 sessionPOST merge, 8 guest504 timeoutretry, same body200 merged, 36save, rendersign out: clear
The merge is one server step: a union by product id that keeps the newest time. Because it is idempotent, the retry after a timeout is safe even if the first request was applied. Sign-out clears the browser copy; the account keeps its history.

Signing out on a shared computer

When a shopper signs out, clear the local list, and tell other tabs to clear theirs (the next chapter shows how). The server copy keeps the history safe for their next sign-in. If you don't clear it, the next person at a library or family computer opens the store and sees the last person's browsing, which is a real privacy failure.

There is a quieter version of the same bug. Sessions also end without a sign-out click: the token expires, or the session is revoked on the server. If the local list then stays, and a different person signs in on that browser, the merge would copy the first person's history into the second person's account.

The fix is an owner tag: while signed in, store a hash of the account id with the list. On sign-in, merge only entries with no owner (guest views) or the same owner; drop the rest. The auth tokens lesson covers detecting the end of a session.

Check your understanding · Exercise 4

The local list is [A at 10:05, B at 10:00] and the server list is [B at 10:07, C at 09:00]. What does the merged list look like, and why is the merge safe to retry? Select all that apply.

Keeping tabs in step

Shoppers compare in tabs. They open three products from a search page, look at each, and go back to the first tab. If that tab's rail was drawn at load time, it doesn't show the other two, which is exactly when the rail would help. Two more cases: removing an item on the Manage history page should take it out of every open rail, and signing out in one tab must clear the rail in all of them.

Decision How does tab B learn that tab A changed the list?

Re-read on focus

Listen for visibilitychange and re-read storage when the tab becomes visible.

Pros

  • A few lines, no extra API.
  • Covers the main case: the shopper switches back to a tab.

Cons

  • A tab shown side by side in another window never updates.

The storage event

Every other same-origin tab gets a storage event when a localStorage key changes, with the key, old value, and new value.

Pros

  • Built in, free with the chosen store.
  • The writing tab does not get the event, so no echo loops.

Cons

  • Only for localStorage; it says what changed, not why (a view versus a sign-out).

BroadcastChannel

All tabs open new BroadcastChannel("rv") and post messages like { type: "cleared" }.

Pros

  • Any message, any store, including IndexedDB.
  • Carries intent, such as a sign-out.

Cons

  • You must still write the data somewhere; the message is only a signal.

What we pick: Use the storage event for list changes, since the list is in localStorage anyway, plus a re-read on visibilitychange as a cheap safety net. Add a BroadcastChannel only for intent that is not a list change, such as sign-out, if the auth layer doesn't already broadcast it. The cross-tab sync lesson covers both APIs in depth.

ts
const KEY = "rv:v1";

window.addEventListener("storage", (e) => {
  if (e.storageArea !== localStorage) return;
  if (e.key === KEY || e.key === null) {   // null: storage was cleared
    historyStore.set(readHistory());       // re-render the rail
  }
});

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") historyStore.set(readHistory());
});

Two tabs writing at once

localStorage has no transactions. If tab A and tab B both read the list, each add a view, and each write, the second write erases the first view. Two rules make this rare enough to accept for history.

First, always re-read the stored list right before writing, inside the same synchronous function, never from a copy held in memory since page load. Second, write only on real events (a view every few seconds at most), not on a timer. The window for a clash is then a few milliseconds. If you need a guarantee, wrap the read and write in navigator.locks.request("rv", ...), the Web Locks API, which lets only one tab hold the lock at a time.

Writes to the server are batched separately: each tab queues views and flushes them in one beacon (see the rendering chapter), and the server applies them with the same union rule, so the order in which tabs flush doesn't matter. The notifications center guide meets the same many-writers problem with its unread badge. In the lab below, turn off the listener and the re-read to see a lost update, then remove an item and pause history.

Tab A
Tab B
Settings
Tab A shows nothing. Tab B shows nothing. localStorage holds nothing.Tab Ain syncTab Bin synclocalStorage rv:v1
Two tabs of the same store, side by side. Both read rv:v1 from localStorage.

Two tabs of the same store, side by side. Both read rv:v1 from localStorage.

Check your understanding · Exercise 5

Tab A records a view and writes the list to localStorage. Which statements are true? Select all that apply.

Privacy, consent, and control

Browsing history is personal data. Design for it from the first sketch, not as a settings page added later. Three questions shape this part: does storing it need consent, which controls shoppers get, and how to protect shared devices and sensitive products.

In the EU and the UK, storing information on a device, localStorage included, needs consent unless it is strictly necessary for a service the user asked for, such as a cart or a sign-in session. Regulator guidance, such as the ICO's in the UK and the Article 29 Working Party's opinion on the consent exemptions in the EU, judges this from the user's point of view. Both treat personalisation that the user did not explicitly ask for as not strictly necessary.

A recently viewed rail that the store shows on its own initiative most likely counts as personalisation. So the safe design records nothing, locally or on the server, until the shopper has accepted the preferences category (the cookie consent guide's name for personalisation storage), and shows no rail until then. This is not legal advice: the exact category is your legal team's call, but the code must be able to gate it. The cookie consent guide covers how to gate storage; this feature only asks consent.has("preferences") before every write.

A Manage history page

Amazon's browsing history page lets shoppers remove a single item, remove all items, and turn browsing history off. That set is the right baseline:

ControlClient actionServer action
Remove oneRemove the entry from the local list; other tabs update through the storage event.DELETE /me/recently-viewed/{productId}
Clear allWrite an empty list.DELETE /me/recently-viewed
PauseSet paused: true in the stored object; the view watcher checks it before recording.PUT /me/recently-viewed/settings with { paused: true }, so other devices stop too.

Pause must be enforced in two places: the client skips recording, and the server rejects or ignores view writes for paused accounts. The server check catches an old tab or an old app version that doesn't know about the pause yet.

Removing one item has a subtle bug to avoid: if the shopper removes a product and the server merge later receives an older local copy that still has it, the item comes back. Fix it by sending removals to the server before the next merge, or by having the server keep a short list of removed ids with their removal time and ignore views older than that. The offline sync engine guide calls these markers tombstones and covers them in depth.

Shared devices and sensitive products

  • Tag the local list with its owner and clear it on sign-out, as the sign-in chapter showed.
  • Show the rail only to its owner: never render one shopper's history in a page that a CDN might cache for others (see the next chapter).
  • Some categories reveal health, sexuality, religion, or finances. A common approach is to exclude products marked sensitive (pregnancy tests, adult items) from recording, or to record them but never show them in the home page rail, where someone looking over a shoulder would see them. Make it a product flag on the server, not a client-side list of words.
  • Store ids, not search terms, URLs with query strings, or anything typed by the shopper.

Check your understanding · Exercise 6

A shopper pauses history on their laptop. Which behaviours are correct? Select all that apply.

Architecture, data flow, and API

The feature is a handful of small modules around one store. The boundaries come from the decisions: the view rule lives in one place so every page uses it, storage hides behind one interface so the hybrid can switch by identity, and the rail only reads.

PieceJobWhy it is separate
View watcherRuns on product pages; applies the visible for 3 seconds or engaged rule; skips prerender, pause, and missing consent.One rule for every page and every team.
History storeHolds the list in memory, exposes add, remove, clear, pause, and notifies subscribers.The single source of truth for the UI.
Local adapterReads and writes rv:v1 in localStorage, listens to the storage event, tags the owner.Swappable for IndexedDB later without touching the UI.
Server syncQueues views, flushes with sendBeacon, calls merge on sign-in, clears on sign-out.Runs only when signed in; knows about auth, nothing else does.
Details fetcherBatches ids into one GET /products/summary call, caches results for a minute.Fresh price and stock, shared by the rail and the Manage page.
Rail componentRenders cards from store plus details; lazy loaded below the fold.Pure UI, no storage knowledge.
Manage history pageLists, removes, clears, pauses.Uses the same store, so tabs stay consistent.
Architecture. In the browser, the ViewWatcher adds views to the HistoryStore, which holds the list and its subscribers and passes the list to the RecentRail. The HistoryStore reads and writes the localAdapter, which owns the rv:v1 key and the storage event, and queues views to serverSync, which sends beacons and merges. The RecentRail sends ids to the detailsFetcher. On the server, serverSync talks to the History API at /me/recently-viewed, and the detailsFetcher calls the Product API at /products/summary.BrowserViewWatcher3 s or engagedHistoryStorelist, subscribersRecentRailcards, lazyaddlistserverSyncbeacon, mergelocalAdapterrv:v1, storagedetailsFetchersummary, 60 squeueread, writeidsServerHistory API/me/recently-viewedProduct API/products/summarybeacon, mergeGET by idsprivate per account
The store is the only thing the UI touches. Storage, the server, and fresh product details each sit behind their own small module, so the rail never knows whether the shopper is a guest.

The data flow for the main screen, a product page with the rail, runs in two directions. On the way in, the page HTML arrives from the CDN without any history in it, so it stays cacheable for everyone. After hydration, the store reads the local list, the rail draws snapshot cards in reserved space, and one summary request fills in prices. For a signed-in shopper, a GET /me/recently-viewed with Cache-Control: private, no-store replaces the local list if it differs.

On the way out, the watcher adds a view to the store, which writes localStorage (other tabs hear about it) and queues a beacon to the server.

Data flow for a product page. 1: the CDN sends the shared HTML, with no history in it, to the page, which reserves the rail slot. 2: the HistoryStore reads the local list from localStorage. 3: it sends the ids to the Product API, and 4: gets prices and stock back, cacheable for 60 seconds. 5: for a signed-in shopper it gets the account list from the History API, private and not stored. The store renders the rail. 6: the ViewWatcher adds a view after 3 seconds. 7: the store writes localStorage, and 8: other tabs hear a storage event. 9: the watcher sends a beacon to the History API.CDNshared HTMLPage + railslot reserved1 HTMLProduct APImax-age 60HistoryStoreone listlocalStorage2 read7 write3 ids4 pricesrenderHistory APIprivateViewWatcherafter 3 sOther tabsstorage event5 list6 view8 event9 beacon
Steps 1 to 5 are the read path and 6 to 9 the write path. Nothing per user crosses the CDN, which is what keeps product pages cacheable; the account list travels only on a private response.

The API and the rail's props

Six endpoints cover the server side, all scoped to the signed-in account through the session, so no account id ever appears in the URL.

http
GET    /me/recently-viewed?limit=20
  200 { entries: ViewEntry[], paused: boolean }

POST   /me/recently-viewed/views        (sendBeacon, batched)
  body { views: [{ productId, variantId, viewedAt }] }
  204   (ignored if paused; future times clamped to now)

POST   /me/recently-viewed/merge
  body { entries: ViewEntry[] }          (guest entries only)
  200 { entries: ViewEntry[], paused: boolean }

DELETE /me/recently-viewed/{productId}   204
DELETE /me/recently-viewed               204
PUT    /me/recently-viewed/settings      { paused: boolean }

Product details come from a separate, shared endpoint, so guests use it too: GET /products/summary?ids=p1,p2,p3 returns one ProductSummary per id, with available: false for deleted ones. It is not per user, so it can be cached briefly at the CDN (say max-age=60, an estimate) keyed by the sorted id list. The rail treats any failure as "show snapshot cards without prices, retry later", never as a page error.

ts
type RecentlyViewedRailProps = {
  heading: string;            // "Recently viewed", translated
  excludeId?: string;         // the product on this page
  max?: number;               // default 20
  onItemClick?: (productId: string, position: number) => void; // analytics
  showManageLink?: boolean;   // link to the Manage history page
};
The rail's props. Heading and exclusion make it reusable on the home page and the product page.

The onItemClick callback hands clicks to the analytics SDK; the rail itself sends nothing. On the product page, excludeId keeps the product you are looking at out of its own rail, which otherwise always takes the first slot and looks broken.

Check your understanding · Exercise 7

Which API choices are sound? Select all that apply.

Rendering the rail without slowing the page

The naive rail reads localStorage during render. On a server-rendered page that breaks twice: the server has no localStorage, so its HTML has no rail, and the browser's first render does have one, so React reports a hydration mismatch. The quick fix, an empty div filled in after load, makes the rail pop in and push the footer down.

Decision When and where should the rail render?

Server-render it per user

Read the account's list on the server and put the cards in the HTML.

Pros

  • Visible in the first paint.

Cons

  • The page becomes per user, so it loses shared CDN caching.
  • Guests' lists live in the browser, so it can't work for most visitors.

Client-only, after hydration, reserved height

Server sends a section with a fixed min-height and skeleton cards; after hydration the store fills it.

Pros

  • The page HTML stays the same for everyone and cacheable.
  • No layout shift: the cards replace skeletons of the same size.

Cons

  • A short skeleton flash; harmless below the fold.

What we pick: Render on the client after hydration into a reserved slot. On product pages the rail sits below the fold, and a layout shift only counts toward CLS when the moving content is in the viewport, so late rendering is free there. On the home page, where it may be visible at once, the reserved height is what keeps CLS at 0. If the list is empty, remove the section before the shopper scrolls to it. Server rendering only pays off on a page that is already private, such as a signed-in home page sent with Cache-Control: private.

The rail itself is a list, not a widget: a section with an h2 ("Recently viewed"), then a ul whose items are each one link to a product. Each card is a single tab stop, so a keyboard user moves card by card and sees a clear focus ring.

The row scrolls with CSS scroll-snap-type: x mandatory, and two buttons labelled "Scroll left" and "Scroll right" call scrollBy with the visible width of the row, using smooth scrolling only when prefers-reduced-motion allows it. Images use loading="lazy" with fixed width and height. The image carousel guide covers the scroller in depth; the semantic HTML, keyboard navigation, and image optimization lessons cover the rest.

Recording without blocking the page

The watcher must never compete with the first paint or first input. Load its code after the page is interactive, write localStorage inside requestIdleCallback with a timeout, and queue server writes in memory. Safari does not enable requestIdleCallback by default yet, so fall back to setTimeout.

Flush the queue when the page is hidden, with navigator.sendBeacon: the browser sends it even if the tab is closing, and it doesn't hold up the next page. A batch of views is far below the limit of 64 KiB that browsers allow for beacons and other keepalive requests in flight.

ts
const queue: { productId: string; variantId?: string; viewedAt: number }[] = [];
const idle: (cb: () => void, opts?: IdleRequestOptions) => number =
  window.requestIdleCallback?.bind(window) ?? ((cb) => window.setTimeout(cb, 1));

export function record(entry: ViewEntry) {
  if (isPaused() || !consent.has("preferences")) return;
  idle(() => {
    saveLocal(addView(readHistory(), entry)); // try/catch inside
    if (isSignedIn()) queue.push({ productId: entry.productId, variantId: entry.variantId, viewedAt: entry.viewedAt });
  }, { timeout: 2000 });
}

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "hidden" || !queue.length) return;
  const body = new Blob([JSON.stringify({ views: queue })], { type: "application/json" });
  if (navigator.sendBeacon("/me/recently-viewed/views", body)) queue.length = 0;
  // false: the browser refused it; keep the queue and try on the next hide
});
Sequence for recording a view. The browser tab tells the watcher the page has been visible for 3 seconds. In an idle callback the watcher writes localStorage. On the failure path localStorage throws QuotaExceededError and the watcher keeps the list in memory. The rail updates in the tab. Later the tab is hidden, and the watcher hands its queued views to sendBeacon. The browser posts them to the History API even if the tab is closing. On the failure path sendBeacon returns false and the watcher keeps its queue. On the next page load the tab gets the account list from the History API.Browser tabWatcherlocalStorageHistory APIvisible 3 sidle: writeor quota errorrail updatestab hiddensendBeaconPOST viewsfalse: keep queuenext load: GET
Nothing here runs before the page is interactive, and every failure is quiet: a storage error falls back to memory, and a refused beacon keeps the queue for the next visibilitychange. The page never sees the beacon's response; the next load's GET is the check.

Failures stay quiet. If localStorage throws, the store keeps the list in memory for this page and the rail still works. If the beacon is refused or lost, the next page load's GET brings the server list, and the local list fills the gap. Losing a view now and then is acceptable; slowing a product page is not.

Check your understanding · Exercise 8

Which choices keep the rail from hurting Core Web Vitals? Select all that apply.

Running it in the interview

In 30 minutes, spend about 5 on questions (guests, cross-device, regions, cap), 3 naming what makes it hard, 15 on the four big decisions (view rule, storage split, merge, tabs), and the rest on privacy, rendering, and follow-ups. Draw the architecture early, with the watcher, store, local adapter, and server sync as boxes, and point at it as each decision lands.

A 30 minute interview plan. Minutes 0 to 5: questions. 5 to 8: what makes it hard. 8 to 23: the four decisions, view rule, storage, merge, and tabs. 23 to 30: privacy and the UI. Notes: draw the architecture by minute 10 and point at it per decision; say each trade-off aloud and raise sign-out and consent before being asked.QuestionsHardView, store, merge, tabsPrivacy, UI0582330 mindraw architecture by min 10point at it per decisionsay each trade-off aloudraise sign-out and consent
Half the time goes to the four decisions. Draw the architecture early, so each later answer has a box to point at.

Say the trade-offs out loud: "Visibility plus 3 seconds, because prerenders are invisible." "Local for guests, server for accounts, union by id on sign-in, because it is idempotent." "Never trust a stored price." Raise sign-out on shared computers and consent before being asked. Likely follow-ups: recommendations (the server already has account history; ranking is out of scope), and two devices pausing and viewing at once (the server enforces pause, so any view that arrives after it is ignored).

Summary

  • A view is intent, not a page load: count it when the page is visible for about 3 seconds or the shopper engages, and never during a prerender.
  • The list is a set ordered by recency: viewing again moves the entry to the front, variants share the parent product id, and the tail is evicted at the cap.
  • Store it locally for guests and on the server for accounts. A cookie loses: 4 KB, sent with every request, and bad for caching.
  • Keep a small snapshot for instant paint, but always fetch price, stock, and existence fresh.
  • On sign-in the server merges a union by product id with the newest time, which is idempotent. Tag the local list with its owner, and clear it on sign-out.
  • Tabs stay in step through the storage event plus a re-read on focus; re-read right before every write.
  • History is personal data: gate it behind consent (the preferences category), offer remove, clear, and pause, and enforce pause on the server.
  • Render the rail after hydration into reserved space, record views when idle, and send them with sendBeacon.

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 3
Mid-level 0 of 4

References

Concepts behind it

Try next