DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design #8: Design a Nested Comments System
XLinkedInReddit
Frontend Engineering

System Design #8: Design a Nested Comments System

D
DevPrep Team
7 min readยท1
Table of Contents
  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Data Structure: Flat vs. Nested
  • Component Tree
  • Rendering Strategy: Flattened Virtual List
  • D โ€” Data Model
  • Server Response
  • Optimistic Comment State
  • I โ€” Interface Definition
  • API Endpoints
  • Real-Time Events
  • O โ€” Optimizations
  • 1. Depth Capping with "Continue Thread"
  • 2. Collapse State Persistence
  • 3. Deep Link with Context Loading
  • 4. Virtualized Comments for Large Threads
  • 5. Markdown Rendering with Sanitization
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

Hey folks, Rahul here ๐Ÿ‘‹

Comments seem trivial until you need nesting. Reddit, Hacker News, GitHub โ€” they all solve the same recursive data problem differently. And the frontend challenges go way beyond rendering a tree: think optimistic inserts, collapse state, real-time streaming, pagination within threads, and sort order that doesn't break when new comments arrive mid-scroll.

This is one of those problems where a naive recursive approach works for 50 comments but melts the browser at 5,000. Let's build it right.

R โ€” Requirements

Functional Requirements

  • Display comments in a threaded/nested tree structure
  • Users can reply to any comment (creating child nodes)
  • Collapse/expand individual threads
  • Sort comments by: newest, oldest, most liked, controversial
  • Like/dislike individual comments
  • Edit and delete own comments
  • Load more replies (pagination within threads)
  • "Jump to parent" for deeply nested contexts

Non-Functional Requirements

  • Performance: Render 5,000+ comments without jank
  • Optimistic UI: New comments appear instantly before server confirmation
  • Real-time: New comments from other users stream in without refresh
  • Accessibility: Proper tree role semantics and focus management
  • Deep linking: Direct URL to any comment with context

A โ€” Architecture

Data Structure: Flat vs. Nested

The core architectural decision is how you store and render the tree. There are two approaches:

Option A: Recursive Nested Objects โŒ

interface NestedComment {
  id: string;
  content: string;
  children: NestedComment[]; // Recursive
}
// Problem: Updating a deeply nested comment requires deep cloning
// Problem: React can't efficiently diff deep trees
// Problem: Normalization is painful

Option B: Flat Map + Parent References โœ…

interface Comment {
  id: string;
  parentId: string | null;
  content: string;
  authorId: string;
  depth: number;
  childCount: number;
  createdAt: string;
}

// Store as a flat normalized map
interface CommentsState {
  byId: Record<string, Comment>;
  rootIds: string[];          // Top-level comment IDs in sort order
  childrenMap: Record<string, string[]>; // parentId โ†’ sorted child IDs
  collapsedIds: Set<string>; // Which threads are collapsed
}

Why flat? Updating a single comment is O(1) โ€” just update byId[id]. Adding a child is O(1) โ€” push to childrenMap[parentId]. React diffs are shallow. This is the Reddit/HN pattern.

Component Tree

CommentsSection
โ”œโ”€โ”€ CommentSortBar          // Sort controls
โ”œโ”€โ”€ CommentComposer         // Top-level "Add comment" form
โ”œโ”€โ”€ CommentThread           // Recursive renderer
โ”‚   โ”œโ”€โ”€ CommentNode         // Single comment
โ”‚   โ”‚   โ”œโ”€โ”€ CommentContent  // Rendered markdown/text
โ”‚   โ”‚   โ”œโ”€โ”€ CommentActions  // Like, Reply, Edit, Delete
โ”‚   โ”‚   โ””โ”€โ”€ ReplyComposer   // Inline reply form (toggled)
โ”‚   โ”œโ”€โ”€ CollapseToggle      // [โˆ’] / [+] thread toggle
โ”‚   โ””โ”€โ”€ CommentThread[]     // Recursive children
โ””โ”€โ”€ LoadMoreButton          // "Show 42 more replies"

Rendering Strategy: Flattened Virtual List

Here's the key insight: don't render the tree recursively with React components. Instead, flatten the visible tree into a list and render it linearly with indentation:

function flattenVisibleComments(
  state: CommentsState,
  maxDepth: number = 10
): FlatComment[] {
  const result: FlatComment[] = [];
  
  function walk(id: string, depth: number) {
    const comment = state.byId[id];
    if (!comment) return;
    
    result.push({ ...comment, depth, isCollapsed: state.collapsedIds.has(id) });
    
    // Don't recurse into collapsed threads
    if (state.collapsedIds.has(id)) return;
    
    // Don't recurse beyond max depth (show "Continue thread โ†’")
    if (depth >= maxDepth) return;
    
    const children = state.childrenMap[id] || [];
    for (const childId of children) {
      walk(childId, depth + 1);
    }
  }
  
  for (const rootId of state.rootIds) {
    walk(rootId, 0);
  }
  
  return result;
}

// Render with indentation via margin-left
{flatList.map(comment => (
  <div key={comment.id} style={{ marginLeft: comment.depth * 24 }}>
    <CommentNode comment={comment} />
  </div>
))}

This approach means you can plug in a virtual list (like react-window) directly โ€” it's just a flat array with varying indentation. At 5,000 comments, only ~20 are in the viewport.

D โ€” Data Model

Server Response

interface CommentResponse {
  id: string;
  parent_id: string | null;
  content: string;
  author: {
    id: string;
    username: string;
    avatar_url: string;
  };
  likes_count: number;
  dislikes_count: number;
  child_count: number;       // Total descendants (for "N replies" label)
  depth: number;             // Server-computed for validation
  created_at: string;
  updated_at: string;
  is_edited: boolean;
  user_vote: 'like' | 'dislike' | null; // Current user's vote
}

// API returns paginated flat list, client builds the tree
interface CommentsPageResponse {
  comments: CommentResponse[];
  total_count: number;
  has_more: boolean;
  cursor: string;            // For cursor-based pagination
}

Optimistic Comment State

interface OptimisticComment extends Comment {
  _optimistic: true;
  _tempId: string;     // Client-generated UUID
  _status: 'pending' | 'confirmed' | 'failed';
}

function addOptimisticComment(state: CommentsState, draft: CommentDraft): CommentsState {
  const tempId = crypto.randomUUID();
  const optimistic: OptimisticComment = {
    id: tempId,
    _tempId: tempId,
    _optimistic: true,
    _status: 'pending',
    parentId: draft.parentId,
    content: draft.content,
    authorId: currentUser.id,
    depth: draft.parentId ? state.byId[draft.parentId].depth + 1 : 0,
    childCount: 0,
    createdAt: new Date().toISOString(),
  };
  
  return {
    ...state,
    byId: { ...state.byId, [tempId]: optimistic },
    rootIds: draft.parentId ? state.rootIds : [tempId, ...state.rootIds],
    childrenMap: draft.parentId
      ? { ...state.childrenMap, [draft.parentId]: [tempId, ...(state.childrenMap[draft.parentId] || [])] }
      : state.childrenMap,
  };
}

// On server confirmation: swap tempId โ†’ real ID
function confirmComment(state: CommentsState, tempId: string, realComment: Comment): CommentsState {
  const newById = { ...state.byId };
  delete newById[tempId];
  newById[realComment.id] = realComment;
  
  // Update all references from tempId to realId
  const updateRefs = (ids: string[]) => ids.map(id => id === tempId ? realComment.id : id);
  
  return {
    ...state,
    byId: newById,
    rootIds: updateRefs(state.rootIds),
    childrenMap: Object.fromEntries(
      Object.entries(state.childrenMap).map(([k, v]) => [
        k === tempId ? realComment.id : k,
        updateRefs(v)
      ])
    ),
  };
}

I โ€” Interface Definition

API Endpoints

// GET /api/comments?post_id={id}&sort={sort}&cursor={cursor}&limit=20
// Returns: CommentsPageResponse (flat list, sorted)

// GET /api/comments/{id}/replies?cursor={cursor}&limit=10
// Returns: CommentsPageResponse (children of a specific comment)

// POST /api/comments
// Body: { post_id, parent_id?, content }
// Returns: CommentResponse

// PATCH /api/comments/{id}
// Body: { content }
// Returns: CommentResponse

// DELETE /api/comments/{id}
// Returns: 204 (soft delete โ€” shows "[deleted]")

// POST /api/comments/{id}/vote
// Body: { type: "like" | "dislike" | "none" }
// Returns: { likes_count, dislikes_count }

Real-Time Events

// Subscribe to comment changes via WebSocket/SSE
interface CommentEvent {
  type: 'new_comment' | 'edit_comment' | 'delete_comment' | 'vote_update';
  comment: CommentResponse;
  post_id: string;
}

// Handle real-time inserts without disrupting scroll
function handleNewComment(event: CommentEvent, state: CommentsState): CommentsState {
  // Don't insert if it's our own optimistic comment
  if (state.byId[event.comment.id]) return state;
  
  const comment = normalizeComment(event.comment);
  
  // If it's a root comment while sorted by "newest", prepend
  // If sorted by "most liked", don't auto-insert โ€” show "N new comments" banner
  if (!comment.parentId && sortMode === 'newest') {
    return insertComment(state, comment);
  }
  
  // For replies, insert into the parent's children
  if (comment.parentId && state.byId[comment.parentId]) {
    return insertReply(state, comment);
  }
  
  // Otherwise, buffer and show notification
  return { ...state, pendingCount: state.pendingCount + 1 };
}

O โ€” Optimizations

1. Depth Capping with "Continue Thread"

// Reddit caps at depth 10, then shows "Continue this thread โ†’"
const MAX_RENDER_DEPTH = 10;

function CommentThread({ commentId, depth }: { commentId: string; depth: number }) {
  const comment = useComment(commentId);
  const children = useChildren(commentId);
  
  if (depth >= MAX_RENDER_DEPTH && children.length > 0) {
    return (
      <div>
        <CommentNode comment={comment} depth={depth} />
        <Link to={`/comments/${commentId}`} className="text-primary text-sm ml-6">
          Continue this thread โ†’
        </Link>
      </div>
    );
  }
  // ... normal recursive render
}

2. Collapse State Persistence

// Persist collapse state in sessionStorage so refreshes maintain context
function useCollapseState(postId: string) {
  const [collapsed, setCollapsed] = useState<Set<string>>(() => {
    const saved = sessionStorage.getItem(`collapsed-${postId}`);
    return saved ? new Set(JSON.parse(saved)) : new Set();
  });
  
  const toggle = useCallback((commentId: string) => {
    setCollapsed(prev => {
      const next = new Set(prev);
      next.has(commentId) ? next.delete(commentId) : next.add(commentId);
      sessionStorage.setItem(`collapsed-${postId}`, JSON.stringify([...next]));
      return next;
    });
  }, [postId]);
  
  return { collapsed, toggle };
}

3. Deep Link with Context Loading

// URL: /post/123#comment-456
// Need to load the comment + its ancestor chain for context
async function loadCommentWithContext(commentId: string): Promise<Comment[]> {
  // Server endpoint returns the comment + all ancestors up to root
  const { data } = await api.get(`/comments/${commentId}/context`);
  // Returns: [root, ..., parent, target_comment]
  
  // Auto-expand all ancestors and scroll to target
  for (const comment of data) {
    expandThread(comment.id);
  }
  
  // After render, scroll to the target comment
  requestAnimationFrame(() => {
    document.getElementById(`comment-${commentId}`)?.scrollIntoView({
      behavior: 'smooth',
      block: 'center'
    });
  });
  
  return data;
}

4. Virtualized Comments for Large Threads

// Using the flattened list with react-window
import { VariableSizeList } from 'react-window';

function VirtualizedComments({ flatComments }: { flatComments: FlatComment[] }) {
  const listRef = useRef<VariableSizeList>(null);
  const heights = useRef<Map<string, number>>(new Map());
  
  const getItemSize = (index: number) => {
    return heights.current.get(flatComments[index].id) || 120; // Estimated height
  };
  
  const setItemSize = (id: string, height: number) => {
    heights.current.set(id, height);
    listRef.current?.resetAfterIndex(
      flatComments.findIndex(c => c.id === id)
    );
  };
  
  return (
    <VariableSizeList
      ref={listRef}
      height={window.innerHeight - 200}
      itemCount={flatComments.length}
      itemSize={getItemSize}
      overscanCount={5}
    >
      {({ index, style }) => (
        <div style={style}>
          <MeasuredComment
            comment={flatComments[index]}
            onMeasure={setItemSize}
          />
        </div>
      )}
    </VariableSizeList>
  );
}

5. Markdown Rendering with Sanitization

import DOMPurify from 'dompurify';
import { marked } from 'marked';

function CommentContent({ content }: { content: string }) {
  const html = useMemo(() => {
    const raw = marked.parse(content, { breaks: true });
    return DOMPurify.sanitize(raw, {
      ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'blockquote'],
      ALLOWED_ATTR: ['href', 'rel', 'target'],
    });
  }, [content]);
  
  return (
    <div
      className="prose prose-sm max-w-none"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

  1. Sort + Real-time Conflict: If sorted by "most liked" and a new comment arrives via WebSocket, don't inject it at the top โ€” it has 0 likes. Show a "New comments available" banner instead.
  2. Delete Semantics: Never hard-delete comments with children. Show "[deleted]" placeholder to preserve thread structure. Only hard-delete leaf comments.
  3. Reply Form Focus: When "Reply" is clicked, the inline composer appears and steals focus. On mobile, this triggers the keyboard, which scrolls the page. Use scrollIntoView({ block: 'nearest' }) to minimize disruption.
  4. Recursive useEffect: Don't fetch children inside a recursive component's useEffect โ€” you'll fire N requests for N visible threads simultaneously. Batch: fetch the first 2 levels of comments in a single API call.
  5. XSS via Markdown: Always sanitize user-generated HTML. Markdown libraries like marked can produce <img onerror="alert(1)"> from crafted input. DOMPurify is non-negotiable.

Next up: #9: Design a Collaborative Text Editor โ€” CRDTs vs OT, cursor presence, conflict resolution, and the operational complexity behind Google Docs. โœ๏ธ

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

9 min read

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Data Structure: Flat vs. Nested
  • Component Tree
  • Rendering Strategy: Flattened Virtual List
  • D โ€” Data Model
  • Server Response
  • Optimistic Comment State
  • I โ€” Interface Definition
  • API Endpoints
  • Real-Time Events
  • O โ€” Optimizations
  • 1. Depth Capping with "Continue Thread"
  • 2. Collapse State Persistence
  • 3. Deep Link with Context Loading
  • 4. Virtualized Comments for Large Threads
  • 5. Markdown Rendering with Sanitization
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

Series

View all Frontend Engineering articles โ†’

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

ยฉ 2026 DevPrep. All rights reserved.