Design a news feed
Lead a frontend system design interview for a news feed. Cover requirements, state ownership, data fetching, rendering, accessibility, performance, testing, and rollout.
Answer Strategy
A news feed is the system design question that exposes whether you can name *what to NOT do*. Don't prepend live updates into the user's scroll without consent (jumps lose place). Don't paginate by offset (pages drift on insert). Don't fetch the next page on viewport entry without debouncing (rapid scroll triggers a storm). Don't hold every post in memory forever (long sessions OOM). Saying these tradeoffs first lifts the answer above the obvious.
Separate five owners. Cursor pagination is the API contract (server returns posts + nextCursor). Cache layer dedupes by id and preserves rank order. Live update layer queues new posts and offers a "N new posts" pill instead of jumping the user. Render layer uses role=feed with aria-posinset/aria-setsize so screen readers can navigate. URL state owns the deep link (a single post ID, or a profile filter) so refresh does not lose the user's reading position.
Volunteer the production failures. Stale cursor on cache invalidation drops the user mid-feed. Real-time websocket reconnect replays missed posts and the dedupe layer must hold. Mute/block lists are server-authoritative but the UI must hide locally between server pushes. Image and video LCP must be measured because the feed is usually the slowest render on mobile. The reference kernel handles dedupe + live merge; the rest is the architecture conversation around it.
Reference Implementation: Feed Kernel With Dedupe And Live Pending Pile
appendFeedPage and mergeLiveUpdates are pure. The NewsFeed component composes them with state, subscriptions, and ARIA semantics for assistive tech.
type FeedPost = {
id: string;
authorId: string;
body: string;
createdAt: number;
rankScore: number;
};
type FeedPage = {
posts: FeedPost[];
nextCursor: string | null;
};
// Pure pagination helper: dedupe across pages by post id, preserve server
// rank order, and return the freshest cursor seen. This is the kernel a
// React component (or a query library) wraps with cache + UI state.
function appendFeedPage(existing: FeedPost[], next: FeedPage): FeedPost[] {
const seen = new Set(existing.map((post) => post.id));
const merged = [...existing];
for (const post of next.posts) {
if (!seen.has(post.id)) {
seen.add(post.id);
merged.push(post);
}
}
return merged;
}
// Live updates from a websocket land here. Decide insertion order:
// "newest first" prepends; "ranked" rebuilds with sort. Production code
// usually shows a "N new posts" pill instead of jumping the user's scroll.
function mergeLiveUpdates(
visible: FeedPost[],
pending: FeedPost[],
options: { autoPrepend: boolean }
): { visible: FeedPost[]; pending: FeedPost[] } {
if (options.autoPrepend) {
const seen = new Set(visible.map((post) => post.id));
const fresh = pending.filter((post) => !seen.has(post.id));
return { visible: [...fresh, ...visible], pending: [] };
}
return { visible, pending };
}
type FeedProps = {
initial: FeedPage;
loadMore: (cursor: string) => Promise<FeedPage>;
subscribe: (handler: (post: FeedPost) => void) => () => void;
};
export function NewsFeed({ initial, loadMore, subscribe }: FeedProps) {
const [posts, setPosts] = React.useState<FeedPost[]>(initial.posts);
const [cursor, setCursor] = React.useState<string | null>(initial.nextCursor);
const [pending, setPending] = React.useState<FeedPost[]>([]);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
return subscribe((post) => {
setPending((current) => [post, ...current]);
});
}, [subscribe]);
async function next() {
if (!cursor || loading) return;
setLoading(true);
try {
const page = await loadMore(cursor);
setPosts((current) => appendFeedPage(current, page));
setCursor(page.nextCursor);
} finally {
setLoading(false);
}
}
function showPending() {
const merged = mergeLiveUpdates(posts, pending, { autoPrepend: true });
setPosts(merged.visible);
setPending(merged.pending);
}
return (
<section role="feed" aria-busy={loading}>
{pending.length > 0 && (
<button type="button" aria-live="polite" onClick={showPending}>
{pending.length} new post{pending.length === 1 ? '' : 's'}
</button>
)}
<ul>
{posts.map((post, index) => (
<li key={post.id}>
<article aria-posinset={index + 1} aria-setsize={posts.length}>
{post.body}
</article>
</li>
))}
</ul>
{cursor && (
<button type="button" onClick={next} disabled={loading}>
{loading ? 'Loading...' : 'Load more'}
</button>
)}
</section>
);
}Runnable Playground
Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.
type FeedPost = {
id: string;
authorId: string;
body: string;
createdAt: number;
rankScore: number;
};
type FeedPage = {
posts: FeedPost[];
nextCursor: string | null;
};
function appendFeedPage(existing: FeedPost[], next: FeedPage): FeedPost[] {
const seen = new Set(existing.map((post) => post.id));
const merged = [...existing];
for (const post of next.posts) {
if (!seen.has(post.id)) {
seen.add(post.id);
merged.push(post);
}
}
return merged;
}
function mergeLiveUpdates(
visible: FeedPost[],
pending: FeedPost[],
options: { autoPrepend: boolean }
): { visible: FeedPost[]; pending: FeedPost[] } {
if (options.autoPrepend) {
const seen = new Set(visible.map((post) => post.id));
const fresh = pending.filter((post) => !seen.has(post.id));
return { visible: [...fresh, ...visible], pending: [] };
}
return { visible, pending };
}
Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect } from 'vitest';
const post = (id: string) => ({
id,
authorId: 'a',
body: id,
createdAt: 0,
rankScore: 0,
});
describe('appendFeedPage', () => {
it('appends new posts and dedupes by id', () => {
const merged = appendFeedPage([post('1'), post('2')], {
posts: [post('2'), post('3')],
nextCursor: null,
});
expect(merged.map((p) => p.id)).toEqual(['1', '2', '3']);
});
});
describe('mergeLiveUpdates', () => {
it('prepends pending posts when autoPrepend is true', () => {
const result = mergeLiveUpdates([post('1')], [post('2')], { autoPrepend: true });
expect(result.visible.map((p) => p.id)).toEqual(['2', '1']);
expect(result.pending).toEqual([]);
});
it('keeps pending posts in the queue when autoPrepend is false', () => {
const result = mergeLiveUpdates([post('1')], [post('2')], { autoPrepend: false });
expect(result.visible.map((p) => p.id)).toEqual(['1']);
expect(result.pending.map((p) => p.id)).toEqual(['2']);
});
});Interviewer Signal
Shows whether you can turn a broad product surface into a durable frontend architecture with clear contracts.
Constraints
- Spend the first five minutes on requirements and non-goals.
- Name client, server, cache, and URL state separately.
- Include accessibility, performance, and observability before the end.
Model Answer Shape
- Clarify users, scale, latency, collaboration, offline, and device constraints.
- Draw the route/component/data-flow shape before diving into component props.
- Choose explicit boundaries for API clients, cache, local state, design-system primitives, and tests.
Tradeoffs
- Generic primitives increase reuse but require stronger documentation and ownership.
- Client-side richness improves speed after load but can raise hydration and bundle costs.
- Real-time updates help freshness but complicate ordering, backpressure, and recovery.
Edge Cases
- Slow network and partial data.
- Permission changes while the user is on the page.
- Large datasets, long sessions, and stale caches.
Testing And Proof
- Contract tests for API adapters.
- Interaction tests for critical workflows.
- Performance budget and E2E scenario for the most important path.
Follow-Ups
- How would you roll this out safely to 1% of users?
- What would become a shared platform primitive after the second product adopted it?