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
| Question | Answer this guide assumes | Why 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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?
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.
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(); } };
}
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.
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?
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
| Situation | Read from | Write to |
|---|---|---|
| Guest | localStorage | localStorage only. |
| Signed in, page load | localStorage first (instant), then the server list replaces it | Both: local at once, server in a batched beacon. |
| Signing in | Merge local into server, then use the merged list | Server, then overwrite local with the result. |
| Signing out | Nothing | Clear the local list. |
| History paused | The existing list, read only | Nothing. |
| No consent yet (EU, UK) | Nothing: no rail | Nothing. |
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?
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.
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
};
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.
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.
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?
Hide them from the rail at once, so the shopper never clicks into a dead page, and show the next 3 entries instead if there are more.
Remove them from the stored list too, so they don't take part in every future refresh. For sold-out but still listed items, keep them and show an Out of stock label: the shopper may want to know.
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?
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.
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);
}
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.
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?
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.
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.
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.
Does storing it need consent?
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:
| Control | Client action | Server action |
|---|---|---|
| Remove one | Remove the entry from the local list; other tabs update through the storage event. | DELETE /me/recently-viewed/{productId} |
| Clear all | Write an empty list. | DELETE /me/recently-viewed |
| Pause | Set 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.
| Piece | Job | Why it is separate |
|---|---|---|
| View watcher | Runs 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 store | Holds the list in memory, exposes add, remove, clear, pause, and notifies subscribers. | The single source of truth for the UI. |
| Local adapter | Reads and writes rv:v1 in localStorage, listens to the storage event, tags the owner. | Swappable for IndexedDB later without touching the UI. |
| Server sync | Queues 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 fetcher | Batches 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 component | Renders cards from store plus details; lazy loaded below the fold. | Pure UI, no storage knowledge. |
| Manage history page | Lists, removes, clears, pauses. | Uses the same store, so tabs stay consistent. |
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.
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.
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.
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 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?
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.
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
});
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.
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
storageevent 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.
References
- MDN: Storage quotas and eviction criteria The 5 MiB per origin limit for localStorage, and how other stores are sized.
- MDN: Window storage event Which documents receive it and what it carries.
- MDN: BroadcastChannel Messages between same-origin tabs, for intent such as sign-out.
- MDN: Document.prerendering Detecting a prerendered page and the prerenderingchange event.
- Chrome for Developers: Prerender pages in Chrome Speculation Rules, the Sec-Purpose header, and deferring analytics until activation.
- MDN: Window.requestIdleCallback() Running low-priority work when the main thread is idle, with a timeout; check browser support.
- MDN: Navigator.sendBeacon() Sending small batches as the page is hidden or closed.
- web.dev: Cumulative Layout Shift Which shifts count, and why reserved space keeps CLS at 0.
- ICO: Guidance on the use of storage and access technologies UK rules for cookies and local storage: the strictly necessary exemption and why personalisation usually needs consent.
- Article 29 Working Party: Opinion 04/2012 on cookie consent exemption The EU view of which storage is exempt, judged from the user's point of view.
- Amazon: Manage your browsing history Remove one item, remove all, and turn browsing history off.
Concepts behind it
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.