2026-05-11
News Feed (Facebook) — Front End System Design
Vietnamese
System Design
AI Summarized

Nguồn: GreatFrontEnd Cập nhật: 2026 Đối tượng: Frontend engineer (junior → senior) muốn hiểu sâu cách thiết kế một news feed như Facebook/Twitter


Mục lục

  1. Đặt vấn đề
  2. Yêu cầu hệ thống
  3. Kiến trúc tổng thể
  4. Data Model — Mô hình dữ liệu
  5. API Design — Thiết kế API
  6. Feed List — Hiển thị danh sách bài
  7. Feed Post — Tối ưu từng bài viết
  8. Feed Composer — Soạn bài viết
  9. Networking, Resilience & Offline
  10. Live Updates — Cập nhật thời gian thực
  11. Performance & Core Web Vitals
  12. Accessibility — Khả năng tiếp cận
  13. Internationalization (i18n)
  14. Telemetry & Observability
  15. Tóm tắt & Thứ tự trả lời phỏng vấn

1. Đặt vấn đề

News feed là một trong những bài toán frontend system design kinh điển vì nó buộc bạn phải đánh đổi (tradeoff) rất nhiều thứ:

  • Rendering performance: Feed có hàng trăm bài, DOM phình to → giật lag
  • Pagination: Dữ liệu thay đổi liên tục, cần chiến lược phân trang đúng
  • State management: Cùng một user/post có thể xuất hiện ở nhiều nơi → đồng bộ khó
  • Interaction latency: Like/react phải cảm giác tức thì dù network chậm
  • Long-lived session: User mở tab cả ngày, feed có thể stale (cũ)
  • Offline: Mất mạng giữa chừng → không được mất action của user

Câu hỏi phỏng vấn thường là:

"Thiết kế một web application cho phép user duyệt feed bài viết, react bài viết, và tạo bài mới. Tập trung vào kiến trúc frontend và giao tiếp client/server."

Ví dụ thực tế: Facebook, Twitter/X, Quora, Reddit.


2. Yêu cầu hệ thống

2.1 Clarification questions (nên hỏi trong phỏng vấn)

Trước khi bắt tay vào thiết kế, luôn hỏi để xác định scope:

Câu hỏiTrả lời mặc định
Core features là gì?Duyệt feed, like/react, tạo bài mới
Loại bài nào?Text + image. Video/poll nếu có thời gian
Pagination UX?Infinite scroll
Cần mobile không?Web-first, mobile-friendly là bonus
Cần comments không?Không trong scope chính, nhưng có thể thảo luận thêm
Cần SEO không?Không — feed đã đăng nhập, cá nhân hóa

2.2 Functional Requirements (tính năng bắt buộc)

  • Duyệt feed: Xem bài của bạn bè và bản thân, cuộn vô tận (infinite scroll)
  • Reactions: Like, love, haha, wow, sad, angry — cập nhật tức thì
  • Tạo bài: Soạn text, đính kèm ảnh, đăng lên feed
  • Quản lý session dài: Khi tab mở lâu, thông báo có bài mới thay vì tự reload

2.3 Non-functional Requirements (chất lượng hệ thống)

  • LCP < 2.5s: Bài đầu tiên phải hiện nhanh
  • INP < 200ms: Mọi tương tác (scroll, click like) phải mượt
  • CLS < 0.1: Trang không bị nhảy layout khi ảnh load
  • Offline read: Vẫn đọc được feed khi mất mạng (từ cache)
  • No data loss: Mất mạng giữa chừng không được mất action

3. Kiến trúc tổng thể

3.1 Rendering Strategy — CSR hay SSR?

Đây là quyết định đầu tiên và quan trọng nhất.

Server-Side Rendering (SSR)

Server tạo ra toàn bộ HTML rồi gửi về browser. User thấy nội dung ngay khi trang load xong.

Client                    Server
  |--- GET /feed -------->|
  |                       | Tạo HTML với data
  |<-- HTML đầy đủ -------|
  | Hiển thị ngay         |
  | (hydration)           |

Ưu điểm: First paint nhanh, SEO tốt (crawler đọc được HTML) Nhược điểm: Mỗi request đều tốn server, khó giữ state phức tạp

Client-Side Rendering (CSR)

Server chỉ trả về HTML shell + JavaScript. Browser tự fetch data và render.

Client                    Server
  |--- GET /feed -------->|
  |<-- HTML shell + JS ---|
  | Parse & run JS        |
  |--- GET /api/feed ---->|
  |<-- JSON data ---------|
  | Render UI             |

Ưu điểm: Sau lần load đầu, navigation cực nhanh; giữ state tốt trong session dài Nhược điểm: Lần đầu tiên chậm hơn SSR một chút

Hybrid

Kết hợp cả hai: trang đầu render trên server, sau đó dùng CSR cho các route còn lại.

Quyết định cho home feed

→ Chọn CSR cho signed-in home feed.

Lý do: Feed đã đăng nhập là nội dung cá nhân hóa → SEO không quan trọng. Điều quan trọng hơn là:

  • Giữ state trong browser suốt session (scroll position, cached posts, optimistic updates)
  • Navigate từ feed → post detail → back feed gần như tức thì vì data đã có trong store
  • User có thể scroll hàng giờ mà không cần reload

Dùng SSR cho: public post permalink, logged-out pages, marketing pages — những trang cần SEO.

Trong interview: Nếu bị hỏi nhanh về rendering strategy, trả lời "CSR cho signed-in home feed, SSR cho public pages" là câu trả lời an toàn.

Framework như Next.js hay TanStack Start cho phép mix cả hai strategies theo từng route.

3.2 Navigation — SPA hay MPA?

Multi-Page Application (MPA)

Mỗi lần navigate → browser load trang mới từ đầu. State bị xóa hoàn toàn.

Single Page Application (SPA)

App load một lần, navigate bằng JavaScript (thay đổi URL, fetch data, update DOM). State tồn tại xuyên suốt session.

→ Chọn SPA.

Lý do: Khi user click vào một bài viết từ feed, trong SPA:

  • Text, ảnh, thông tin tác giả có thể đã có trong store rồi
  • Post detail page render gần như tức thì
  • Khi back về feed: scroll position và data vẫn còn nguyên

Trong MPA: mỗi navigate đều xóa sạch state → phải fetch lại mọi thứ → chậm và tốn bandwidth.

3.3 Bốn Layer kiến trúc

Sau khi chọn CSR + SPA, chia frontend thành 4 layers rõ ràng:

┌──────────────────────────────────────────────────────┐
│                    VIEW LAYER                        │
│  React Components: FeedPage, FeedPost, Composer...   │
│  Chỉ quan tâm: render data + handle user events      │
├──────────────────────────────────────────────────────┤
│                   STORE LAYER                        │
│  Zustand / Redux / Jotai                             │
│  Source of truth: posts, users, feed, composer draft │
│  Chỉ quan tâm: quản lý state, optimistic updates     │
├──────────────────────────────────────────────────────┤
│               DATA ACCESS LAYER                      │
│  TanStack Query / SWR / Relay / Apollo               │
│  Chỉ quan tâm: fetch, cache, normalize, retry        │
├──────────────────────────────────────────────────────┤
│                  SERVER LAYER                        │
│  HTTP API: /feed, /posts, /reactions, /media         │
│  Chỉ quan tâm: cung cấp data canonical               │
└──────────────────────────────────────────────────────┘

Tại sao chia layer?

Mỗi layer có trách nhiệm riêng biệt (Separation of Concerns). Nếu không chia:

  • Networking code lẫn vào component → khó test, khó tái sử dụng
  • Cache logic nằm rải rác → inconsistent behavior
  • State update ở nhiều nơi → bug khó trace

Ví dụ setup:

// App.tsx — wiring tất cả layers lại với nhau
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import FeedPage from './pages/FeedPage'
import PostDetailPage from './pages/PostDetailPage'

// QueryClient là "Data Access Layer" — xử lý fetch, cache, retry
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Sau 1 phút, data được coi là stale → refetch khi focus lại tab
      staleTime: 1000 * 60,
      // Giữ cache 5 phút sau khi component unmount
      gcTime: 1000 * 60 * 5,
      // Tự động retry 3 lần nếu request thất bại
      retry: 3,
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30_000),
    },
  },
})

export default function App() {
  return (
    // QueryClientProvider cung cấp Data Access Layer cho toàn app
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<FeedPage />} />
          <Route path="/posts/:postId" element={<PostDetailPage />} />
        </Routes>
      </BrowserRouter>
    </QueryClientProvider>
  )
}

4. Data Model — Mô hình dữ liệu

4.1 Tại sao data model quan trọng?

Data model quyết định:

  • Bạn cập nhật state như thế nào khi user like một bài
  • Bài đó được reflect ở bao nhiêu nơi trên UI
  • Có phải fetch lại không, hay chỉ cần update một chỗ trong memory

4.2 Post Entity

// Loại reaction mà user có thể chọn
type ReactionType = 'like' | 'love' | 'haha' | 'wow' | 'sad' | 'angry'

// Body của post — lưu dạng plain text + metadata về entities
// KHÔNG lưu HTML để tránh XSS
type PostBody = {
  text: string  // plain text, ví dụ: "Xem thử @greatfrontend tại đây #webdev"
  entities: Array<{
    type: 'mention' | 'hashtag' | 'link'
    start: number  // index bắt đầu trong string (inclusive)
    end: number    // index kết thúc (exclusive, giống String.slice)
    userId?: string  // chỉ có nếu type === 'mention'
    url?: string     // chỉ có nếu type === 'link'
  }>
}

// Thống kê tương tác của bài viết
type EngagementSummary = {
  reactions: Record<ReactionType, number>  // { like: 150, love: 23, ... }
  totalReactions: number                   // tổng tất cả reactions
  commentCount: number
  shareCount: number
}

// Entity chính — đây là "canonical" post được lưu trong store
type Post = {
  id: string
  authorId: string    // reference đến User, không embed trực tiếp
  body: PostBody
  mediaIds: string[]  // reference đến Media, không embed trực tiếp
  engagementSummary: EngagementSummary
  viewerReaction: ReactionType | null  // reaction hiện tại của người đang xem
  viewerHasShared: boolean
  createdAt: number   // Unix timestamp (seconds)
}

Chú ý quan trọng: authorIdmediaIdsreferences (ID), không phải object lồng nhau. Lý do sẽ giải thích ở phần Normalized Store.

4.3 User Entity

type RelationshipToViewer = {
  isFriend?: boolean    // có phải bạn bè không
  isFollowing?: boolean // mình có đang follow họ không
  isMuted?: boolean     // có đang mute họ không
  isBlocked?: boolean   // có đang block họ không
}

type User = {
  id: string
  name: string           // tên hiển thị: "Nguyễn Văn A"
  handle: string         // username: "@nguyenvana"
  profilePhotoUrl: string
  isVerified: boolean    // tick xanh
  relationshipToViewer: RelationshipToViewer
}

4.4 Media Entity

type Media = {
  id: string
  src: string           // URL gốc
  previewSrc?: string   // URL thumbnail/blur placeholder để show trước khi load xong
  alt: string           // mô tả cho screen reader và SEO
  width: number         // cần để reserve không gian → tránh CLS
  height: number
}

4.5 Feed Entity

Feed không phải là một mảng bài viết lồng nhau. Feed là danh sách ID cộng với metadata để phân trang.

type Feed = {
  id: string             // ví dụ: "home-feed"
  postIds: string[]      // ["post_1", "post_3", "post_7"...] — ordered list
  olderCursor: string | null  // dùng để load bài cũ hơn (scroll xuống)
  newerCursor: string | null  // dùng để check bài mới hơn (khi feed stale)
  hasOlder: boolean      // còn bài cũ hơn để load không?
  hasNewer: boolean      // có bài mới hơn không?
  lastFetchedAt: number | null  // timestamp lần fetch gần nhất
}

4.6 Normalized Store — Tại sao QUAN TRỌNG?

Vấn đề với Denormalized Store (nested objects)

Tưởng tượng bạn lưu user info bên trong từng post:

// ❌ Denormalized — user bị duplicate trong nhiều posts
const store = {
  posts: [
    {
      id: 'post_1',
      author: { id: 'user_1', name: 'An', profilePhotoUrl: '...' },
      // ...
    },
    {
      id: 'post_2',
      author: { id: 'user_1', name: 'An', profilePhotoUrl: '...' }, // DUPLICATE!
      // ...
    },
    // User 1 có thể là author của 20 bài → bị copy 20 lần
  ]
}

Vấn đề:

  • User đổi avatar → phải tìm và update trong tất cả 20 bài
  • Dễ bị inconsistent (bài 1 hiện avatar cũ, bài 2 hiện avatar mới)
  • Tốn memory vì data bị duplicate

Normalized Store — giải pháp đúng

// ✅ Normalized — mỗi entity lưu đúng MỘT lần, reference bằng ID
type Store = {
  feedsById: Record<string, Feed>    // { "home-feed": Feed }
  postsById: Record<string, Post>    // { "post_1": Post, "post_2": Post, ... }
  usersById: Record<string, User>    // { "user_1": User, "user_2": User, ... }
  mediaById: Record<string, Media>   // { "media_1": Media, ... }
  composerDraft: ComposerDraft       // state của ô soạn bài
}

type ComposerDraft = {
  body: PostBody
  mediaIds: string[]
  uploadState: 'idle' | 'uploading' | 'failed'
  submitState: 'idle' | 'submitting' | 'submitted' | 'failed'
}

Lợi ích:

  • User đổi avatar → chỉ cần update usersById['user_1']tất cả nơi dùng user đó tự cập nhật
  • Cùng post xuất hiện trên home feed và post detail page → dùng chung một record
  • Reaction update → chỉ update một Post object → phản ánh khắp nơi

Implement với Zustand

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer' // dùng immer để mutate state dễ hơn

interface FeedStore {
  // State
  postsById: Record<string, Post>
  usersById: Record<string, User>
  mediaById: Record<string, Media>
  feedsById: Record<string, Feed>
  composerDraft: ComposerDraft

  // Actions
  mergeResponse: (data: ApiResponse) => void
  appendOlderPosts: (feedId: string, postIds: string[], olderCursor: string | null, hasOlder: boolean) => void
  applyReactionOptimistic: (postId: string, reaction: ReactionType | null) => void
  rollbackReaction: (postId: string, previousReaction: ReactionType | null) => void
  prependNewPost: (post: Post, author: User, media: Media[]) => void
  updateComposerDraft: (partial: Partial<ComposerDraft>) => void
}

export const useFeedStore = create<FeedStore>()(
  immer((set) => ({
    // Initial state
    postsById: {},
    usersById: {},
    mediaById: {},
    feedsById: {},
    composerDraft: {
      body: { text: '', entities: [] },
      mediaIds: [],
      uploadState: 'idle',
      submitState: 'idle',
    },

    // Nhận response từ API và merge vào store
    // API trả về nested objects → ta normalize tại đây
    mergeResponse: (data) =>
      set((state) => {
        // Merge posts
        for (const post of data.posts ?? []) {
          state.postsById[post.id] = post
        }
        // Merge users
        for (const user of data.users ?? []) {
          state.usersById[user.id] = user
        }
        // Merge media
        for (const media of data.media ?? []) {
          state.mediaById[media.id] = media
        }
        // Update feed postIds và cursor
        if (data.feedId && data.postIds) {
          const feed = state.feedsById[data.feedId]
          if (feed) {
            feed.postIds = data.postIds
            feed.olderCursor = data.olderCursor ?? null
            feed.hasOlder = data.hasOlder ?? false
            feed.lastFetchedAt = Date.now()
          } else {
            state.feedsById[data.feedId] = {
              id: data.feedId,
              postIds: data.postIds,
              olderCursor: data.olderCursor ?? null,
              newerCursor: null,
              hasOlder: data.hasOlder ?? false,
              hasNewer: false,
              lastFetchedAt: Date.now(),
            }
          }
        }
      }),

    // Thêm bài cũ hơn khi user scroll xuống
    appendOlderPosts: (feedId, newPostIds, olderCursor, hasOlder) =>
      set((state) => {
        const feed = state.feedsById[feedId]
        if (!feed) return
        // Thêm ID vào cuối (bài cũ hơn)
        feed.postIds = [...feed.postIds, ...newPostIds]
        feed.olderCursor = olderCursor
        feed.hasOlder = hasOlder
      }),

    // Cập nhật reaction ngay lập tức (optimistic) không cần chờ server
    applyReactionOptimistic: (postId, reaction) =>
      set((state) => {
        const post = state.postsById[postId]
        if (!post) return

        const prevReaction = post.viewerReaction

        // Cập nhật reaction của viewer
        post.viewerReaction = reaction

        // Cập nhật totalReactions
        if (prevReaction && !reaction) {
          // Bỏ reaction
          post.engagementSummary.totalReactions -= 1
          post.engagementSummary.reactions[prevReaction] -= 1
        } else if (!prevReaction && reaction) {
          // Thêm reaction mới
          post.engagementSummary.totalReactions += 1
          post.engagementSummary.reactions[reaction] =
            (post.engagementSummary.reactions[reaction] ?? 0) + 1
        } else if (prevReaction && reaction && prevReaction !== reaction) {
          // Đổi reaction
          post.engagementSummary.reactions[prevReaction] -= 1
          post.engagementSummary.reactions[reaction] =
            (post.engagementSummary.reactions[reaction] ?? 0) + 1
        }
      }),

    // Rollback về state trước nếu server trả lỗi
    rollbackReaction: (postId, previousReaction) =>
      set((state) => {
        const post = state.postsById[postId]
        if (!post) return
        post.viewerReaction = previousReaction
        // Recalculate totalReactions từ reactions object
        post.engagementSummary.totalReactions = Object.values(
          post.engagementSummary.reactions
        ).reduce((sum, count) => sum + count, 0)
      }),

    // Thêm bài mới lên đầu feed sau khi tạo thành công
    prependNewPost: (post, author, media) =>
      set((state) => {
        state.postsById[post.id] = post
        state.usersById[author.id] = author
        for (const m of media) {
          state.mediaById[m.id] = m
        }
        const homeFeed = state.feedsById['home-feed']
        if (homeFeed) {
          homeFeed.postIds = [post.id, ...homeFeed.postIds]
        }
        // Reset composer
        state.composerDraft = {
          body: { text: '', entities: [] },
          mediaIds: [],
          uploadState: 'idle',
          submitState: 'idle',
        }
      }),

    updateComposerDraft: (partial) =>
      set((state) => {
        Object.assign(state.composerDraft, partial)
      }),
  }))
)

5. API Design — Thiết kế API

5.1 Nguyên tắc bảo mật quan trọng

Identity phải đến từ session cookie, KHÔNG nhận userId từ request body hay query params.

Nếu client gửi userId, attacker có thể giả mạo và đọc feed của người khác. Server phải lấy userId từ session/token đã được xác thực.

5.2 Fetch Feed

GET /feed?cursor=<olderCursor>&count=20&direction=older
ParameterMô tả
cursorID của bài cuối cùng đã load (để load bài cũ hơn)
countSố lượng bài muốn load (mặc định 20)
directionolder = cuộn xuống, newer = check bài mới

Response mẫu:

{
  "feedId": "home-feed",
  "postIds": ["post_3", "post_7", "post_12"],
  "olderCursor": "post_12",
  "newerCursor": "post_3",
  "hasOlder": true,
  "hasNewer": false,
  "posts": [
    {
      "id": "post_3",
      "authorId": "user_42",
      "body": {
        "text": "Hello @everyone #webdev",
        "entities": [
          { "type": "mention", "start": 6, "end": 14, "userId": "user_99" },
          { "type": "hashtag", "start": 15, "end": 22 }
        ]
      },
      "mediaIds": [],
      "engagementSummary": {
        "reactions": { "like": 150, "love": 23 },
        "totalReactions": 173,
        "commentCount": 45,
        "shareCount": 12
      },
      "viewerReaction": null,
      "viewerHasShared": false,
      "createdAt": 1715680000
    }
  ],
  "users": [
    { "id": "user_42", "name": "Nguyễn An", "handle": "@an", "profilePhotoUrl": "...", "isVerified": false, "relationshipToViewer": { "isFriend": true } }
  ],
  "media": []
}

Lưu ý: Server trả về nested response (post kèm users và media liên quan). Data Access Layer sẽ normalize trước khi đưa vào store.

5.3 Cursor-based Pagination — Tại sao không dùng Offset?

Vấn đề với Offset Pagination

Offset pagination: "Cho tôi 3 bài, bắt đầu từ vị trí 3"

Lần 1 fetch: [F, E, D]  (offset=0, limit=3)
  → User đang đọc D

Ai đó đăng bài H và G mới

Lần 2 fetch: ?offset=3&limit=3
  → Server trả: [E, D, C]  ← BỊ TRÙNG! E và D đã có rồi

Vì dataset thay đổi liên tục (bài mới được đăng), offset bị trật bánh.

Cursor Pagination — ổn định hơn

Cursor pagination: "Cho tôi 3 bài, cũ hơn bài D"

Lần 1 fetch: [F, E, D]  → olderCursor = "D"
  → User đang đọc D

Ai đó đăng bài H và G mới

Lần 2 fetch: ?cursor=D&limit=3
  → Server trả: [C, B, A]  ← ĐÚNG! Chỉ load bài cũ hơn D

Cursor là ID của bài cuối cùng — không phụ thuộc vào vị trí tuyệt đối nên ổn định dù feed thay đổi.

Implementation với TanStack Query

// hooks/useFeed.ts
import { useInfiniteQuery } from '@tanstack/react-query'
import { useFeedStore } from '../store/feedStore'

interface FeedPage {
  postIds: string[]
  olderCursor: string | null
  hasOlder: boolean
}

async function fetchFeedPage(cursor: string | null): Promise<FeedPage> {
  const params = new URLSearchParams()
  params.set('count', '20')
  if (cursor) params.set('cursor', cursor)

  const res = await fetch(`/feed?${params}`)
  if (!res.ok) throw new Error(`Feed fetch failed: ${res.status}`)

  const data = await res.json()

  // Normalize: lấy entities ra, đưa vào store
  // (trong thực tế, ta dùng middleware hoặc onSuccess callback)
  return {
    postIds: data.postIds,
    olderCursor: data.olderCursor,
    hasOlder: data.hasOlder,
  }
}

export function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed', 'home'],
    queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
    initialPageParam: null as string | null,
    // Hàm này quyết định cursor để fetch page tiếp theo
    getNextPageParam: (lastPage) =>
      lastPage.hasOlder ? lastPage.olderCursor : undefined,
    // Không stale trong 30 giây (tránh refetch khi focus lại tab quá sớm)
    staleTime: 30_000,
  })
}

5.4 Các Endpoints khác

GET    /posts/{postId}             → Fetch single post (cho post detail page)
PUT    /posts/{postId}/reaction    → Set/change reaction
DELETE /posts/{postId}/reaction    → Remove reaction
POST   /posts                      → Tạo bài viết mới
POST   /media/uploads              → Upload ảnh/video

5.5 Tạo bài viết — Flow đầy đủ

Khi user đăng bài có ảnh:

Step 1: Upload ảnh
POST /media/uploads
Body: FormData với file ảnh
→ Response: { mediaId: "m_1", uploadUrl: "https://cdn.example.com/upload/..." }

Step 2: Upload binary lên CDN (trực tiếp, không qua app server)
PUT {uploadUrl}
Body: binary image data
→ Ảnh được lưu trên CDN

Step 3: Tạo post với mediaId vừa nhận
POST /posts
Body: {
  body: { text: "Ảnh đẹp quá!", entities: [] },
  mediaIds: ["m_1"]
}
→ Response: post object đầy đủ

Tại sao upload thẳng lên CDN thay vì qua app server?

  • Ảnh HD có thể vài MB — đi qua app server tốn bandwidth và memory của server
  • CDN được tối ưu để nhận file lớn

5.6 Idempotency Key — Tránh tạo bài trùng lặp

Vấn đề: Mạng chập chờn, request thất bại, client retry → server tạo 2 bài giống nhau!

Giải pháp: Mỗi mutation đính kèm một unique key. Server nhận key đã thấy → trả về kết quả của lần đầu thay vì tạo mới.

// utils/idempotency.ts
import { v4 as uuidv4 } from 'uuid'

// Tạo key LÚC USER SUBMIT, không phải lúc fire request
// → khi retry, dùng lại cùng key → server biết đây là duplicate
export function generateIdempotencyKey(): string {
  return uuidv4()
}

// Wrapper cho mutation requests
export async function mutateWithIdempotency(
  url: string,
  options: RequestInit,
  idempotencyKey: string
): Promise<Response> {
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Idempotency-Key': idempotencyKey,
      'Content-Type': 'application/json',
    },
  })
}

6. Feed List — Hiển thị danh sách bài

6.1 Vấn đề với DOM lớn

Khi user scroll xuống hàng trăm bài:

  • DOM có thể chứa hàng nghìn nodes
  • Browser phải paint, layout, và track tất cả → giật lag
  • Memory usage tăng vọt

Ví dụ: 200 bài × 50 DOM nodes/bài = 10.000 nodes trong DOM. Quá nhiều.

6.2 Virtualized List — Chỉ render bài trong viewport

Ý tưởng: Chỉ render ~10-15 bài trong và quanh viewport. Bài ngoài viewport → thay bằng <div> trống với đúng chiều cao.

Viewport (màn hình)
┌─────────────────┐
│ Post 5  [REAL]  │ ← đang nhìn thấy
│ Post 6  [REAL]  │ ← đang nhìn thấy
│ Post 7  [REAL]  │ ← đang nhìn thấy
├─────────────────┤
│ Post 8  [REAL]  │ ← overscan (chuẩn bị sẵn)
│ Post 9  [REAL]  │ ← overscan
└─────────────────┘
Post 1-4:  <div height="1200px"> (spacer, không có content)
Post 10+:  <div height="900px">  (spacer, không có content)

Scroll bar vẫn đúng vì tổng height được tính toán.

// components/VirtualFeed.tsx
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useCallback } from 'react'
import { FeedPost } from './FeedPost'

interface VirtualFeedProps {
  postIds: string[]
  onReachBottom: () => void
  isLoadingMore: boolean
}

export function VirtualFeed({ postIds, onReachBottom, isLoadingMore }: VirtualFeedProps) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: postIds.length,
    // Hàm này trả về scroll container
    getScrollElement: () => parentRef.current,
    // Ước tính chiều cao mỗi bài (virtualizer sẽ đo lại sau khi render)
    estimateSize: () => 350,
    // Render thêm 3 bài trên/dưới viewport (để scroll mượt hơn)
    overscan: 3,
  })

  // Trigger load more khi gần đáy
  const handleScroll = useCallback(() => {
    const scrollEl = parentRef.current
    if (!scrollEl || isLoadingMore) return

    const { scrollTop, scrollHeight, clientHeight } = scrollEl
    // Khi còn 1 viewport height nữa là chạm đáy → load trước
    if (scrollHeight - scrollTop - clientHeight < clientHeight) {
      onReachBottom()
    }
  }, [isLoadingMore, onReachBottom])

  return (
    <div
      ref={parentRef}
      onScroll={handleScroll}
      style={{
        height: '100vh',
        overflow: 'auto',
        // Quan trọng: contain: strict giúp browser tối ưu paint
        contain: 'strict',
      }}
    >
      {/* Container với tổng height đúng → scroll bar đúng */}
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={postIds[virtualItem.index]}
            // ref để virtualizer đo chiều cao thực sau khi render
            ref={virtualizer.measureElement}
            data-index={virtualItem.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              // Dịch chuyển đến đúng vị trí trong scroll container
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <FeedPost postId={postIds[virtualItem.index]} />
          </div>
        ))}
      </div>

      {isLoadingMore && (
        <div style={{ padding: 16 }}>
          <SkeletonPost />
          <SkeletonPost />
          <SkeletonPost />
        </div>
      )}
    </div>
  )
}

Lưu ý khi dùng virtualization:

  • Nếu user đang focus vào một element (ví dụ reaction menu) và element đó scroll ra ngoài viewport → bị unmount → mất focus. Cần xử lý trường hợp này bằng cách giữ item mounted nếu đang focused.
  • Browser's Ctrl+F (find in page) chỉ tìm được text đang trong DOM. Bài ngoài viewport sẽ không tìm được.

6.3 Infinite Scroll với Intersection Observer

Thay vì dùng scroll event (sync, tốn main thread), dùng Intersection Observer API — browser tự gọi callback khi element vào/ra viewport.

// hooks/useInfiniteScroll.ts
import { useEffect, useRef } from 'react'

interface UseInfiniteScrollOptions {
  onTrigger: () => void
  isDisabled?: boolean
  // Khoảng cách từ đáy để trigger (ví dụ "200px" = trigger khi còn 200px nữa)
  rootMargin?: string
}

export function useInfiniteScroll({
  onTrigger,
  isDisabled = false,
  rootMargin = '200px',
}: UseInfiniteScrollOptions) {
  const sentinelRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (isDisabled) return

    const sentinel = sentinelRef.current
    if (!sentinel) return

    const observer = new IntersectionObserver(
      (entries) => {
        // entries[0] là sentinel element
        if (entries[0].isIntersecting) {
          onTrigger()
        }
      },
      {
        // rootMargin: trigger trước khi sentinel thực sự vào viewport
        // "200px" = trigger khi sentinel còn cách đáy viewport 200px
        rootMargin,
      }
    )

    observer.observe(sentinel)

    return () => observer.disconnect()
  }, [onTrigger, isDisabled, rootMargin])

  // Trả về ref để gắn vào sentinel element
  return sentinelRef
}

// Dùng trong component
function FeedFooter({ onLoadMore, hasMore, isLoading }: {
  onLoadMore: () => void
  hasMore: boolean
  isLoading: boolean
}) {
  const sentinelRef = useInfiniteScroll({
    onTrigger: onLoadMore,
    isDisabled: !hasMore || isLoading,
    rootMargin: '300px', // Trigger khi còn 300px nữa → user không thấy loading
  })

  return (
    <>
      {/* Sentinel element vô hình — đặt ở cuối list */}
      <div ref={sentinelRef} style={{ height: 1 }} aria-hidden="true" />

      {isLoading && (
        <div role="status" aria-label="Đang tải thêm bài...">
          <SkeletonPost />
          <SkeletonPost />
        </div>
      )}

      {!hasMore && (
        <p style={{ textAlign: 'center', padding: 16, color: '#666' }}>
          Bạn đã xem hết feed 🎉
        </p>
      )}
    </>
  )
}

6.4 Skeleton Loading — Tại sao tốt hơn spinner?

Spinner: "Đang load, không biết load bao lâu, không biết layout sẽ trông thế nào"

Skeleton: "Đang load, và đây là hình dạng xấp xỉ của nội dung sắp xuất hiện"

Skeleton giúp user cảm thấy trang tải nhanh hơn vì não họ đã "preview" được layout.

// components/SkeletonPost.tsx
import './SkeletonPost.css'

export function SkeletonPost() {
  return (
    // aria-hidden: không cần screen reader đọc skeleton
    <div className="skeleton-post" aria-hidden="true">
      {/* Avatar */}
      <div className="skeleton-row">
        <div className="skeleton skeleton-avatar" />
        <div className="skeleton-meta">
          <div className="skeleton skeleton-name" />
          <div className="skeleton skeleton-time" />
        </div>
      </div>

      {/* Nội dung text */}
      <div className="skeleton skeleton-line" style={{ width: '100%' }} />
      <div className="skeleton skeleton-line" style={{ width: '90%' }} />
      <div className="skeleton skeleton-line" style={{ width: '75%' }} />

      {/* Ảnh (không phải lúc nào cũng có, nhưng reserve không gian) */}
      <div className="skeleton skeleton-image" />

      {/* Action buttons */}
      <div className="skeleton-actions">
        <div className="skeleton skeleton-button" />
        <div className="skeleton skeleton-button" />
        <div className="skeleton skeleton-button" />
      </div>
    </div>
  )
}
/* SkeletonPost.css */
.skeleton {
  background: #e0e0e0;
  border-radius: 4px;
}

/* Hiệu ứng shimmer — ánh sáng chạy qua */
@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton {
  background: linear-gradient(
    90deg,
    #e0e0e0 25%,
    #f0f0f0 50%,
    #e0e0e0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

/* Tắt animation nếu user cài prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: none;
    background: #e0e0e0;
  }
}

.skeleton-post { padding: 16px; border-bottom: 1px solid #eee; }
.skeleton-row { display: flex; gap: 12px; margin-bottom: 12px; }
.skeleton-avatar { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
.skeleton-meta { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.skeleton-name { height: 14px; width: 40%; }
.skeleton-time { height: 12px; width: 25%; }
.skeleton-line { height: 14px; margin-bottom: 8px; }
.skeleton-image { height: 300px; width: 100%; margin: 12px 0; border-radius: 8px; }
.skeleton-actions { display: flex; gap: 16px; margin-top: 12px; }
.skeleton-button { height: 32px; width: 80px; border-radius: 4px; }

6.5 Stale Feed — Xử lý khi tab mở lâu

Sau vài tiếng, feed cũ đi. Cách handle:

// components/StaleFeedBanner.tsx
import { useEffect, useState, useCallback } from 'react'

const STALE_AFTER_MS = 30 * 60 * 1000 // 30 phút

interface StaleFeedBannerProps {
  lastFetchedAt: number | null
  newPostCount?: number
  onRefresh: () => void
}

export function StaleFeedBanner({
  lastFetchedAt,
  newPostCount = 0,
  onRefresh,
}: StaleFeedBannerProps) {
  const [isStale, setIsStale] = useState(false)

  useEffect(() => {
    if (!lastFetchedAt) return

    const checkStaleness = () => {
      setIsStale(Date.now() - lastFetchedAt > STALE_AFTER_MS)
    }

    checkStaleness()
    // Kiểm tra mỗi phút
    const interval = setInterval(checkStaleness, 60_000)
    return () => clearInterval(interval)
  }, [lastFetchedAt])

  if (!isStale && newPostCount === 0) return null

  const message = newPostCount > 0
    ? `Có ${newPostCount} bài viết mới`
    : 'Feed của bạn có thể đã cũ'

  return (
    // role="status" + aria-live="polite": screen reader thông báo
    // sau khi user xong nội dung đang đọc, không ngắt giữa chừng
    <div
      role="status"
      aria-live="polite"
      className="stale-banner"
      style={{
        position: 'sticky',
        top: 0,
        zIndex: 10,
        background: '#1877f2',
        color: 'white',
        padding: '8px 16px',
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
      }}
    >
      <span>{message}</span>
      <button
        onClick={onRefresh}
        style={{
          background: 'white',
          color: '#1877f2',
          border: 'none',
          borderRadius: 4,
          padding: '4px 12px',
          cursor: 'pointer',
          fontWeight: 'bold',
        }}
      >
        Tải lại
      </button>
    </div>
  )
}

6.6 Scroll Restoration — Giữ vị trí cuộn khi navigate

Khi user click vào bài, rồi back về feed → feed phải cuộn về đúng chỗ cũ.

// hooks/useScrollRestoration.ts
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'

export function useScrollRestoration(feedId: string) {
  const containerRef = useRef<HTMLDivElement>(null)
  const location = useLocation()

  // Lưu scroll position vào sessionStorage trước khi rời trang
  useEffect(() => {
    const container = containerRef.current
    if (!container) return

    const saveScrollPosition = () => {
      sessionStorage.setItem(
        `scroll-${feedId}`,
        String(container.scrollTop)
      )
    }

    // Lưu trước khi navigate đi
    window.addEventListener('beforeunload', saveScrollPosition)
    return () => window.removeEventListener('beforeunload', saveScrollPosition)
  }, [feedId])

  // Restore scroll position khi mount lại
  useEffect(() => {
    const savedPosition = sessionStorage.getItem(`scroll-${feedId}`)
    if (!savedPosition || !containerRef.current) return

    // Dùng requestAnimationFrame để đảm bảo DOM đã render xong
    requestAnimationFrame(() => {
      if (containerRef.current) {
        containerRef.current.scrollTop = Number(savedPosition)
      }
    })
  }, [feedId])

  return containerRef
}

6.7 Cross-tab Sync với BroadcastChannel

Khi user mở 2 tab: react bài ở tab 1 → tab 2 vẫn hiện trạng thái cũ. Fix:

// utils/crossTabSync.ts

// BroadcastChannel: gửi message đến tất cả tab cùng origin
const channel = new BroadcastChannel('feed-sync')

type SyncMessage =
  | { type: 'REACTION_UPDATED'; postId: string; reaction: ReactionType | null }
  | { type: 'POST_CREATED'; postId: string }

// Gửi update đến các tab khác
export function broadcastUpdate(message: SyncMessage) {
  channel.postMessage(message)
}

// Lắng nghe update từ tab khác
export function listenForUpdates(onMessage: (msg: SyncMessage) => void) {
  channel.addEventListener('message', (event) => {
    onMessage(event.data as SyncMessage)
  })

  return () => channel.close()
}

// Dùng trong React component
function useCrossTabSync() {
  const { applyReactionOptimistic } = useFeedStore()

  useEffect(() => {
    return listenForUpdates((msg) => {
      if (msg.type === 'REACTION_UPDATED') {
        applyReactionOptimistic(msg.postId, msg.reaction)
      }
    })
  }, [applyReactionOptimistic])
}

7. Feed Post — Tối ưu từng bài viết

7.1 Component chính

// components/FeedPost.tsx
import { memo } from 'react'
import { useFeedStore } from '../store/feedStore'
import { PostBody } from './PostBody'
import { FeedImage } from './FeedImage'
import { PostTimestamp } from './PostTimestamp'
import { ReactionBar } from './ReactionBar'

interface FeedPostProps {
  postId: string
}

// memo() để tránh re-render khi parent re-render nhưng post không thay đổi
export const FeedPost = memo(function FeedPost({ postId }: FeedPostProps) {
  const post = useFeedStore((state) => state.postsById[postId])
  const author = useFeedStore((state) =>
    post ? state.usersById[post.authorId] : undefined
  )
  const mediaList = useFeedStore((state) =>
    post?.mediaIds.map((id) => state.mediaById[id]).filter(Boolean) ?? []
  )

  if (!post || !author) return null

  return (
    <article
      role="article"
      aria-labelledby={`author-${postId}`}
      style={{ padding: 16, borderBottom: '1px solid #eee' }}
    >
      {/* Header: Avatar + Tên + Thời gian */}
      <div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
        <img
          src={author.profilePhotoUrl}
          alt={`Ảnh đại diện của ${author.name}`}
          width={40}
          height={40}
          style={{ borderRadius: '50%' }}
          loading="lazy"
        />
        <div>
          <strong id={`author-${postId}`}>{author.name}</strong>
          {author.isVerified && <span aria-label="Đã xác minh"> ✓</span>}
          <br />
          <PostTimestamp createdAt={post.createdAt} />
        </div>
      </div>

      {/* Nội dung bài viết */}
      <PostBody body={post.body} />

      {/* Ảnh đính kèm */}
      {mediaList.map((media) => (
        <FeedImage key={media.id} media={media} />
      ))}

      {/* Reaction bar + comment/share buttons */}
      <ReactionBar post={post} />
    </article>
  )
})

7.2 Render Rich Text an toàn

Quy tắc bất biến: KHÔNG bao giờ inject HTML trực tiếp từ server vào DOM. Đây là lỗ hổng XSS kinh điển.

Thay vào đó, dùng entity ranges để render React nodes:

// components/PostBody.tsx
import React from 'react'
import type { PostBody as PostBodyType } from '../types'

// Validate URL scheme: chỉ cho phép http, https, mailto
// javascript:, data:, vbscript: đều bị block — đây là XSS vectors!
function isSafeUrl(url: string): boolean {
  try {
    const parsed = new URL(url)
    return ['http:', 'https:', 'mailto:'].includes(parsed.protocol)
  } catch {
    return false
  }
}

function renderEntities(body: PostBodyType): React.ReactNode[] {
  const { text, entities } = body
  const nodes: React.ReactNode[] = []
  let cursor = 0

  // Sắp xếp entities theo thứ tự xuất hiện trong text
  const sortedEntities = [...entities].sort((a, b) => a.start - b.start)

  for (const entity of sortedEntities) {
    // 1. Thêm text thường trước entity này
    if (cursor < entity.start) {
      nodes.push(
        <React.Fragment key={`text-${cursor}`}>
          {text.slice(cursor, entity.start)}
        </React.Fragment>
      )
    }

    // 2. Render entity
    const entityText = text.slice(entity.start, entity.end)

    switch (entity.type) {
      case 'mention':
        nodes.push(
          <a
            key={`mention-${entity.start}`}
            href={`/profile/${entity.userId}`}
            style={{ color: '#1877f2', fontWeight: 500, textDecoration: 'none' }}
          >
            {entityText}
          </a>
        )
        break

      case 'hashtag':
        nodes.push(
          <a
            key={`hashtag-${entity.start}`}
            href={`/hashtag/${entityText.slice(1)}`}
            style={{ color: '#1877f2', textDecoration: 'none' }}
          >
            {entityText}
          </a>
        )
        break

      case 'link':
        // Validate trước khi render!
        if (entity.url && isSafeUrl(entity.url)) {
          nodes.push(
            <a
              key={`link-${entity.start}`}
              href={entity.url}
              target="_blank"
              // noopener: ngăn tab mới truy cập window.opener (tab-nabbing attack)
              // noreferrer: không gửi Referer header (privacy)
              rel="noopener noreferrer"
              style={{ color: '#1877f2' }}
            >
              {entityText}
            </a>
          )
        } else {
          // URL không an toàn → render là text thường, không làm link
          nodes.push(
            <span key={`unsafe-link-${entity.start}`}>{entityText}</span>
          )
        }
        break
    }

    cursor = entity.end
  }

  // 3. Thêm phần text còn lại sau entity cuối
  if (cursor < text.length) {
    nodes.push(
      <React.Fragment key={`text-end`}>
        {text.slice(cursor)}
      </React.Fragment>
    )
  }

  return nodes
}

interface PostBodyProps {
  body: PostBodyType
  maxLines?: number
}

export function PostBody({ body, maxLines = 5 }: PostBodyProps) {
  const [isExpanded, setIsExpanded] = React.useState(false)
  const lineCount = body.text.split('\n').length

  const needsTruncation = lineCount > maxLines

  return (
    <div style={{ marginBottom: 12 }}>
      <p
        style={{
          margin: 0,
          // CSS line-clamp để truncate text dài
          ...(needsTruncation && !isExpanded
            ? {
                display: '-webkit-box',
                WebkitLineClamp: maxLines,
                WebkitBoxOrient: 'vertical',
                overflow: 'hidden',
              }
            : {}),
        }}
      >
        {renderEntities(body)}
      </p>

      {needsTruncation && !isExpanded && (
        <button
          onClick={() => setIsExpanded(true)}
          style={{
            background: 'none',
            border: 'none',
            color: '#606770',
            cursor: 'pointer',
            fontWeight: 'bold',
            padding: 0,
            marginTop: 4,
          }}
        >
          Xem thêm
        </button>
      )}
    </div>
  )
}

7.3 Image Optimization

// components/FeedImage.tsx
import { useState } from 'react'
import type { Media } from '../types'

interface FeedImageProps {
  media: Media
}

export function FeedImage({ media }: FeedImageProps) {
  const [isLoaded, setIsLoaded] = useState(false)

  return (
    <div
      style={{
        position: 'relative',
        // Giữ aspect ratio trước khi ảnh load xong → tránh CLS (layout shift)
        aspectRatio: `${media.width} / ${media.height}`,
        background: '#f0f0f0',
        overflow: 'hidden',
        borderRadius: 8,
        marginBottom: 12,
      }}
    >
      {/* Blur placeholder hiện khi ảnh chưa load xong */}
      {media.previewSrc && !isLoaded && (
        <img
          src={media.previewSrc}
          alt=""
          aria-hidden="true"
          style={{
            position: 'absolute',
            inset: 0,
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            filter: 'blur(20px)',
            transform: 'scale(1.1)', // tránh viền trắng do blur
          }}
        />
      )}

      {/* Ảnh thực sự */}
      <picture>
        {/* Browser chọn format tốt nhất mà nó hỗ trợ */}
        <source
          srcSet={`${media.src}?format=avif`}
          type="image/avif"
        />
        <source
          srcSet={`${media.src}?format=webp`}
          type="image/webp"
        />
        <img
          src={media.src}
          alt={media.alt}
          width={media.width}
          height={media.height}
          loading="lazy"      // browser chỉ load khi gần viewport
          decoding="async"    // decode ảnh không block main thread
          onLoad={() => setIsLoaded(true)}
          style={{
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            opacity: isLoaded ? 1 : 0,
            transition: 'opacity 0.3s ease', // fade in khi load xong
          }}
        />
      </picture>
    </div>
  )
}

7.4 Optimistic Updates — Reaction tức thì

Vấn đề: Network request mất 200-500ms. Nếu chờ server response mới update UI → user thấy lag.

Giải pháp: Cập nhật UI ngay, gửi request song song. Nếu lỗi → rollback.

// components/ReactionBar.tsx
import { useState, useRef } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useFeedStore } from '../store/feedStore'
import { broadcastUpdate } from '../utils/crossTabSync'
import { generateIdempotencyKey } from '../utils/idempotency'
import type { Post, ReactionType } from '../types'

const REACTION_EMOJIS: Record<ReactionType, string> = {
  like: '👍',
  love: '❤️',
  haha: '😂',
  wow: '😮',
  sad: '😢',
  angry: '😡',
}

interface ReactionBarProps {
  post: Post
}

export function ReactionBar({ post }: ReactionBarProps) {
  const [showPicker, setShowPicker] = useState(false)
  const { applyReactionOptimistic, rollbackReaction } = useFeedStore()

  // Lưu snapshot để rollback nếu lỗi
  const previousReactionRef = useRef<ReactionType | null>(null)

  const { mutate: react } = useMutation({
    mutationFn: async (newReaction: ReactionType | null) => {
      const key = generateIdempotencyKey() // tạo key LÚC submit

      const res = await (newReaction
        ? fetch(`/posts/${post.id}/reaction`, {
            method: 'PUT',
            headers: {
              'Content-Type': 'application/json',
              'Idempotency-Key': key,
            },
            body: JSON.stringify({ reaction: newReaction }),
          })
        : fetch(`/posts/${post.id}/reaction`, {
            method: 'DELETE',
            headers: { 'Idempotency-Key': key },
          }))

      if (!res.ok) throw new Error('Reaction failed')
      return res.json()
    },

    onMutate: (newReaction) => {
      // Lưu state hiện tại để rollback nếu cần
      previousReactionRef.current = post.viewerReaction

      // Cập nhật UI ngay TRƯỚC KHI gửi request
      applyReactionOptimistic(post.id, newReaction)

      // Sync sang tab khác
      broadcastUpdate({ type: 'REACTION_UPDATED', postId: post.id, reaction: newReaction })
    },

    onError: () => {
      // Request thất bại → rollback về state cũ
      rollbackReaction(post.id, previousReactionRef.current)

      // Thông báo user
      alert('Không thể react bài viết. Vui lòng thử lại.')
    },

    onSuccess: (serverPost: Post) => {
      // Server là authoritative — nếu count khác với optimistic → sync lại
      applyReactionOptimistic(post.id, serverPost.viewerReaction)
    },
  })

  const handleReactionClick = (reaction: ReactionType) => {
    // Click cùng reaction đang có → bỏ reaction
    const newReaction = post.viewerReaction === reaction ? null : reaction
    react(newReaction)
    setShowPicker(false)
  }

  const totalCount = post.engagementSummary.totalReactions

  return (
    <div style={{ marginTop: 8 }}>
      {/* Hiển thị tổng reactions */}
      {totalCount > 0 && (
        <div style={{ fontSize: 14, color: '#606770', marginBottom: 8 }}>
          {post.viewerReaction && REACTION_EMOJIS[post.viewerReaction]}{' '}
          {totalCount >= 1000
            ? `${(totalCount / 1000).toFixed(1)}K`
            : totalCount}{' '}
          lượt thích
        </div>
      )}

      {/* Action buttons */}
      <div style={{ display: 'flex', gap: 8 }}>
        {/* Like button với hold để mở reaction picker */}
        <div style={{ position: 'relative' }}>
          <button
            onClick={() => handleReactionClick('like')}
            onMouseEnter={() => setShowPicker(true)}
            onMouseLeave={() => setShowPicker(false)}
            aria-label={post.viewerReaction ? `Đang ${post.viewerReaction}, click để bỏ` : 'Thích bài viết'}
            aria-pressed={post.viewerReaction !== null}
            style={{
              background: 'none',
              border: '1px solid #ddd',
              borderRadius: 20,
              padding: '6px 16px',
              cursor: 'pointer',
              color: post.viewerReaction ? '#1877f2' : '#606770',
              fontWeight: post.viewerReaction ? 'bold' : 'normal',
            }}
          >
            {post.viewerReaction
              ? `${REACTION_EMOJIS[post.viewerReaction]} ${post.viewerReaction}`
              : '👍 Thích'}
          </button>

          {/* Reaction picker — lazy load */}
          {showPicker && (
            <div
              role="toolbar"
              aria-label="Chọn reaction"
              style={{
                position: 'absolute',
                bottom: '100%',
                left: 0,
                background: 'white',
                borderRadius: 24,
                boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
                padding: '8px 12px',
                display: 'flex',
                gap: 4,
                zIndex: 10,
              }}
            >
              {(Object.entries(REACTION_EMOJIS) as [ReactionType, string][]).map(
                ([type, emoji]) => (
                  <button
                    key={type}
                    onClick={() => handleReactionClick(type)}
                    aria-label={type}
                    title={type}
                    style={{
                      background: 'none',
                      border: 'none',
                      fontSize: 28,
                      cursor: 'pointer',
                      transform: post.viewerReaction === type ? 'scale(1.3)' : 'scale(1)',
                      transition: 'transform 0.15s ease',
                    }}
                  >
                    {emoji}
                  </button>
                )
              )}
            </div>
          )}
        </div>

        <button
          style={{ background: 'none', border: '1px solid #ddd', borderRadius: 20, padding: '6px 16px', cursor: 'pointer', color: '#606770' }}
          aria-label={`${post.engagementSummary.commentCount} bình luận`}
        >
          💬 {post.engagementSummary.commentCount}
        </button>

        <button
          style={{ background: 'none', border: '1px solid #ddd', borderRadius: 20, padding: '6px 16px', cursor: 'pointer', color: '#606770' }}
          aria-label={`${post.engagementSummary.shareCount} lượt chia sẻ`}
        >
          🔁 {post.engagementSummary.shareCount}
        </button>
      </div>
    </div>
  )
}

7.5 Timestamp tự động cập nhật

Vấn đề: "2 phút trước" được render lúc 10:00, nhưng đến 11:00 vẫn hiện "2 phút trước" vì không có gì trigger re-render.

// components/PostTimestamp.tsx
import { useEffect, useState } from 'react'

function getRelativeTime(createdAtSeconds: number): string {
  const now = Date.now()
  const diffMs = now - createdAtSeconds * 1000
  const diffSeconds = Math.floor(diffMs / 1000)
  const diffMinutes = Math.floor(diffSeconds / 60)
  const diffHours = Math.floor(diffMinutes / 60)
  const diffDays = Math.floor(diffHours / 24)

  if (diffSeconds < 60) return 'Vừa xong'
  if (diffMinutes < 60) return `${diffMinutes} phút trước`
  if (diffHours < 24) return `${diffHours} giờ trước`
  if (diffDays < 7) return `${diffDays} ngày trước`

  // Bài cũ hơn 1 tuần: dùng Intl để format đẹp theo locale
  return new Intl.DateTimeFormat('vi-VN', {
    day: 'numeric',
    month: 'long',
    year: diffDays > 365 ? 'numeric' : undefined,
  }).format(new Date(createdAtSeconds * 1000))
}

export function PostTimestamp({ createdAt }: { createdAt: number }) {
  const [displayText, setDisplayText] = useState(() => getRelativeTime(createdAt))

  useEffect(() => {
    const diffMinutes = (Date.now() - createdAt * 1000) / 60_000

    // Chỉ setup timer cho bài DƯỚI 1 GIỜ TUỔI
    // Bài cũ hơn không cần update thường xuyên (giờ → ngày không thay đổi cảm nhận)
    if (diffMinutes >= 60) return

    const timer = setInterval(() => {
      setDisplayText(getRelativeTime(createdAt))
    }, 30_000) // update mỗi 30 giây

    return () => clearInterval(timer)
  }, [createdAt])

  return (
    // <time> với dateTime để máy tính/screen reader đọc được thời gian chính xác
    <time
      dateTime={new Date(createdAt * 1000).toISOString()}
      title={new Intl.DateTimeFormat('vi-VN', {
        dateStyle: 'full',
        timeStyle: 'short',
      }).format(new Date(createdAt * 1000))}
      style={{ fontSize: 12, color: '#606770' }}
    >
      {displayText}
    </time>
  )
}

8. Feed Composer — Soạn bài viết

8.1 Rich Text Editor

<textarea> chỉ render plain text — không highlight được @mention hay #hashtag. Cần dùng contenteditable hoặc editor library.

Đừng dùng contenteditable thô — nó có rất nhiều bugs (clipboard handling, IME, undo history). Dùng library:

  • Lexical (Meta, open source, modern)
  • TipTap (dựa trên ProseMirror, nhiều extensions)
  • Slate (React-first, custom nhất)

Bảo mật khi paste: Khi user paste HTML từ nơi khác → đây là vector XSS!

// Intercept paste event — chỉ nhận plain text, bỏ HTML
function handlePaste(event: ClipboardEvent) {
  event.preventDefault()

  // Chỉ lấy plain text, bỏ qua text/html
  const plainText = event.clipboardData?.getData('text/plain') ?? ''

  // Insert vào editor position hiện tại
  document.execCommand('insertText', false, plainText)
}

8.2 Lazy Load Composer Features

Không phải ai cũng dùng emoji picker hay GIF picker. Chỉ load khi cần:

// components/PostComposer.tsx
import { lazy, Suspense, useState, useRef } from 'react'
import { useFeedStore } from '../store/feedStore'

// Các feature nặng — chỉ import khi user click
const EmojiPicker = lazy(() => import('./EmojiPicker'))
const GifPicker = lazy(() => import('./GifPicker'))
const ImageUploader = lazy(() => import('./ImageUploader'))

export function PostComposer() {
  const [showEmoji, setShowEmoji] = useState(false)
  const [showGif, setShowGif] = useState(false)
  const [showImageUpload, setShowImageUpload] = useState(false)
  const { composerDraft, updateComposerDraft, prependNewPost } = useFeedStore()

  const handleSubmit = async () => {
    if (!composerDraft.body.text.trim() && composerDraft.mediaIds.length === 0) return

    updateComposerDraft({ submitState: 'submitting' })

    try {
      const res = await fetch('/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          body: composerDraft.body,
          mediaIds: composerDraft.mediaIds,
        }),
      })

      if (!res.ok) throw new Error('Submit failed')

      const data = await res.json()
      prependNewPost(data.post, data.users[0], data.media)
    } catch {
      updateComposerDraft({ submitState: 'failed' })
    }
  }

  return (
    <div style={{ padding: 16, background: 'white', borderRadius: 8, marginBottom: 16, boxShadow: '0 1px 4px rgba(0,0,0,0.1)' }}>
      <div
        // contenteditable thô — trong thực tế dùng Lexical/TipTap
        contentEditable
        onInput={(e) =>
          updateComposerDraft({
            body: { text: e.currentTarget.textContent ?? '', entities: [] },
          })
        }
        style={{
          minHeight: 60,
          padding: 12,
          borderRadius: 8,
          background: '#f0f2f5',
          outline: 'none',
          fontSize: 16,
        }}
        role="textbox"
        aria-multiline="true"
        aria-label="Bạn đang nghĩ gì?"
        data-placeholder="Bạn đang nghĩ gì?"
      />

      {/* Toolbar: các feature optional */}
      <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
        <button
          onClick={() => setShowImageUpload(true)}
          aria-label="Thêm ảnh"
        >
          🖼️
        </button>
        <button
          onClick={() => setShowEmoji(true)}
          aria-label="Thêm emoji"
        >
          😊
        </button>
        <button
          onClick={() => setShowGif(true)}
          aria-label="Thêm GIF"
        >
          GIF
        </button>

        <button
          onClick={handleSubmit}
          disabled={
            composerDraft.submitState === 'submitting' ||
            (!composerDraft.body.text.trim() && composerDraft.mediaIds.length === 0)
          }
          style={{
            marginLeft: 'auto',
            background: '#1877f2',
            color: 'white',
            border: 'none',
            borderRadius: 20,
            padding: '8px 20px',
            cursor: 'pointer',
            fontWeight: 'bold',
          }}
          aria-label="Đăng bài"
        >
          {composerDraft.submitState === 'submitting' ? 'Đang đăng...' : 'Đăng'}
        </button>
      </div>

      {/* Lazy load feature panels */}
      {showImageUpload && (
        <Suspense fallback={<div>Đang tải...</div>}>
          <ImageUploader onClose={() => setShowImageUpload(false)} />
        </Suspense>
      )}
      {showEmoji && (
        <Suspense fallback={null}>
          <EmojiPicker onClose={() => setShowEmoji(false)} />
        </Suspense>
      )}
      {showGif && (
        <Suspense fallback={null}>
          <GifPicker onClose={() => setShowGif(false)} />
        </Suspense>
      )}
    </div>
  )
}

9. Networking, Resilience & Offline

9.1 Error Handling

Phân loại lỗi để xử lý đúng cách:

// utils/apiError.ts

type ErrorSeverity = 'transient' | 'permanent' | 'auth'

function classifyHttpError(status: number): ErrorSeverity {
  if ([401, 403].includes(status)) return 'auth'         // hết session → redirect login
  if ([400, 404, 422].includes(status)) return 'permanent' // sẽ không thành công nếu retry
  return 'transient' // 5xx, network error → có thể retry
}

// Exponential backoff: 1s, 2s, 4s, 8s... max 30s, có jitter để tránh thundering herd
function getRetryDelay(attempt: number): number {
  const base = Math.min(1000 * 2 ** attempt, 30_000)
  // Thêm random jitter ±25% để các client không retry đồng thời
  const jitter = base * 0.25 * (Math.random() * 2 - 1)
  return base + jitter
}

9.2 Offline Outbox — Viết khi không có mạng

Vấn đề: User click Like → mất mạng → action bị mất!

Giải pháp: Outbox pattern — lưu action vào IndexedDB trước, gửi network sau.

// utils/outbox.ts
import { openDB } from 'idb'

interface OutboxEntry {
  idempotencyKey: string    // unique key để dedup khi retry
  url: string
  method: string
  body: string
  createdAt: number
  retryCount: number
}

// Khởi tạo IndexedDB
const dbPromise = openDB('feed-outbox', 1, {
  upgrade(db) {
    db.createObjectStore('entries', { keyPath: 'idempotencyKey' })
  },
})

// API của outbox
export const outbox = {
  // Thêm mutation vào hàng đợi
  async add(entry: Omit<OutboxEntry, 'createdAt' | 'retryCount'>) {
    const db = await dbPromise
    await db.put('entries', {
      ...entry,
      createdAt: Date.now(),
      retryCount: 0,
    })
  },

  // Xóa sau khi thành công
  async remove(idempotencyKey: string) {
    const db = await dbPromise
    await db.delete('entries', idempotencyKey)
  },

  // Lấy tất cả entries chưa gửi
  async getAll(): Promise<OutboxEntry[]> {
    const db = await dbPromise
    return db.getAll('entries')
  },
}

// Drain outbox khi có mạng
async function drainOutbox() {
  const entries = await outbox.getAll()

  for (const entry of entries) {
    try {
      const res = await fetch(entry.url, {
        method: entry.method,
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': entry.idempotencyKey,
        },
        body: entry.body,
      })

      if (res.ok) {
        await outbox.remove(entry.idempotencyKey)
      } else if ([400, 404, 422].includes(res.status)) {
        // Permanent error → xóa khỏi outbox, thông báo user
        await outbox.remove(entry.idempotencyKey)
        console.warn('Permanent error, dropping mutation:', entry)
      }
      // 5xx → giữ trong outbox để retry sau
    } catch {
      // Network error → retry sau
    }
  }
}

// Lắng nghe khi có mạng lại → drain outbox
window.addEventListener('online', drainOutbox)

// Wrapper để submit mutation có outbox support
export async function submitWithOutbox(
  url: string,
  method: string,
  body: object,
  idempotencyKey: string
) {
  const entry: Omit<OutboxEntry, 'createdAt' | 'retryCount'> = {
    idempotencyKey,
    url,
    method,
    body: JSON.stringify(body),
  }

  // 1. Lưu vào outbox (IndexedDB) TRƯỚC
  await outbox.add(entry)

  try {
    // 2. Thử gửi ngay nếu có mạng
    const res = await fetch(url, {
      method,
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey,
      },
      body: JSON.stringify(body),
    })

    if (res.ok) {
      // 3. Thành công → xóa khỏi outbox
      await outbox.remove(idempotencyKey)
      return res
    }

    throw new Error(`HTTP ${res.status}`)
  } catch {
    // Mất mạng hoặc server lỗi → giữ trong outbox
    // UI đang hiện optimistic state → user không thấy gì khác
    // Sẽ retry khi có mạng lại
    throw new Error('Saved to outbox, will retry when online')
  }
}

10. Live Updates — Cập nhật thời gian thực

10.1 So sánh các phương pháp

Phương phápCơ chếƯu điểmNhược điểm
Short pollingClient hỏi server mỗi N giâyĐơn giảnTốn bandwidth, nhiều request thừa
Long pollingRequest giữ open cho đến khi có dataĐơn giản hơn WebSocketLatency cao, server phức tạp hơn
SSEServer push qua HTTP connectionAuto-reconnect, đơn giảnChỉ server → client
WebSocketFull-duplex, bidirectionalRealtime thật sự, multiplexPhức tạp hơn, cần WS infrastructure

Với Facebook-scale feed → WebSocket vì:

  • Cùng một connection có thể multiplex: comment updates, reaction counts, typing indicators
  • Client cần gửi subscriptions về bài nào đang xem (bidirectional)

10.2 WebSocket Implementation

// hooks/useLiveUpdates.ts
import { useEffect, useRef } from 'react'
import { useFeedStore } from '../store/feedStore'

type LiveEvent =
  | { type: 'REACTION_COUNT_UPDATED'; postId: string; engagementSummary: EngagementSummary }
  | { type: 'NEW_COMMENT'; postId: string; commentCount: number }
  | { type: 'NEW_POSTS_AVAILABLE'; count: number }

export function useLiveUpdates(visiblePostIds: string[]) {
  const wsRef = useRef<WebSocket | null>(null)
  const { applyReactionOptimistic } = useFeedStore()

  useEffect(() => {
    const ws = new WebSocket('wss://api.example.com/live')
    wsRef.current = ws

    ws.onopen = () => {
      // Subscribe chỉ bài đang nhìn thấy (tối ưu bandwidth)
      ws.send(JSON.stringify({
        type: 'SUBSCRIBE',
        postIds: visiblePostIds,
      }))
    }

    ws.onmessage = (event) => {
      const liveEvent: LiveEvent = JSON.parse(event.data)

      switch (liveEvent.type) {
        case 'REACTION_COUNT_UPDATED':
          // Cập nhật count từ server (authoritative)
          // Dùng server value thay vì optimistic value
          useFeedStore.setState((state) => ({
            postsById: {
              ...state.postsById,
              [liveEvent.postId]: {
                ...state.postsById[liveEvent.postId],
                engagementSummary: liveEvent.engagementSummary,
              },
            },
          }))
          break

        case 'NEW_POSTS_AVAILABLE':
          // Hiện banner "Có X bài mới" — không tự insert vào feed
          // để không làm mất vị trí đọc của user
          useFeedStore.setState({ newPostsAvailable: liveEvent.count })
          break
      }
    }

    ws.onclose = () => {
      // Auto-reconnect sau 3 giây
      setTimeout(() => {
        // reconnect logic
      }, 3000)
    }

    return () => ws.close()
  }, []) // Chỉ setup 1 lần

  // Cập nhật subscriptions khi bài visible thay đổi
  useEffect(() => {
    const ws = wsRef.current
    if (!ws || ws.readyState !== WebSocket.OPEN) return

    ws.send(JSON.stringify({
      type: 'UPDATE_SUBSCRIPTIONS',
      postIds: visiblePostIds,
    }))
  }, [visiblePostIds])
}

11. Performance & Core Web Vitals

11.1 Ba metrics quan trọng nhất

MetricĐo gìTarget
LCP (Largest Contentful Paint)Thời gian bài đầu tiên xuất hiện< 2.5 giây
INP (Interaction to Next Paint)Delay từ click/scroll đến browser vẽ lại< 200ms
CLS (Cumulative Layout Shift)Layout có bị nhảy không khi content load< 0.1

Với news feed, INP quan trọng hơn LCP sau lần load đầu. Vì user scroll và tương tác hàng giờ, INP kém → cảm giác toàn bộ app giật.

11.2 JavaScript Loading Tiers (Facebook model)

Facebook chia JS loading thành 3 tầng:

TIER 1 — Phải load đầu tiên
├── App shell (header, sidebar skeleton)
├── Feed skeleton CSS
└── Critical path CSS

TIER 2 — Load ngay sau khi có pixels trên màn hình
├── Text post renderer
├── Image post renderer
├── Basic interaction handlers (like, comment buttons)
└── Feed scroll logic

TIER 3 — Load khi cần hoặc khi browser idle
├── Reaction picker
├── Hover cards (profile preview khi hover tên)
├── Emoji picker trong composer
├── GIF picker
├── Live update WebSocket client
└── Telemetry / logging
// Tier 3 example: lazy load ReactionPicker
const ReactionPicker = lazy(() =>
  // Chỉ load khi user hover vào Like button
  import('./ReactionPicker').then((module) => ({ default: module.ReactionPicker }))
)

// Prefetch khi browser idle (giữa Tier 2 và user interaction)
if ('requestIdleCallback' in window) {
  requestIdleCallback(() => {
    // Hint browser load trước nhưng không execute
    import('./ReactionPicker')
  })
}

11.3 Đo lường với web-vitals

// utils/webVitals.ts
import { onLCP, onINP, onCLS } from 'web-vitals'

function sendToAnalytics(metric: { name: string; value: number; rating: string }) {
  // Gửi về analytics backend
  navigator.sendBeacon('/analytics', JSON.stringify({
    metric: metric.name,
    value: metric.value,
    rating: metric.rating,       // 'good' | 'needs-improvement' | 'poor'
    deviceType: getDeviceType(), // 'mobile' | 'tablet' | 'desktop'
    connectionType: (navigator as any).connection?.effectiveType, // '4g' | '3g'...
    timestamp: Date.now(),
  }))
}

onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)

12. Accessibility — Khả năng tiếp cận

12.1 Feed semantics

// FeedPage.tsx
export function FeedPage() {
  return (
    <div>
      {/* Skip link: cho phép keyboard user nhảy thẳng vào feed */}
      <a
        href="#main-feed"
        style={{
          // Ẩn đi nhưng hiện khi focus (cho keyboard users)
          position: 'absolute',
          left: '-9999px',
        }}
        onFocus={(e) => (e.currentTarget.style.left = '0')}
        onBlur={(e) => (e.currentTarget.style.left = '-9999px')}
      >
        Bỏ qua navigation, đến nội dung chính
      </a>

      <Header />

      {/* role="feed": nói với screen reader "đây là một luồng nội dung" */}
      {/* aria-busy: thông báo screen reader khi đang load */}
      <main
        id="main-feed"
        role="feed"
        aria-label="Bảng tin của bạn"
        aria-busy={isLoading}
      >
        {postIds.map((id) => (
          <FeedPost key={id} postId={id} />
        ))}
      </main>
    </div>
  )
}

12.2 Post semantics

// FeedPost.tsx (phần accessibility)
<article
  role="article"
  // aria-labelledby: tên bài = tên tác giả (screen reader sẽ đọc tên khi focus)
  aria-labelledby={`author-name-${post.id}`}
  // aria-describedby: mô tả thêm bằng phần body
  aria-describedby={`post-body-${post.id}`}
>
  <h3 id={`author-name-${post.id}`} className="visually-hidden">
    Bài viết của {author.name}
  </h3>
  <p id={`post-body-${post.id}`}>
    {/* nội dung bài */}
  </p>
</article>

12.3 Focus management khi virtualization

Khi bài đang được focused bị virtualize ra khỏi DOM → focus bị mất. Cần handle:

// Trước khi unmount item, check xem nó có chứa focused element không
function handleBeforeUnmount(postElement: HTMLElement) {
  const focusedElement = document.activeElement

  if (postElement.contains(focusedElement)) {
    // Di chuyển focus về feed container để không mất
    const feedContainer = document.getElementById('main-feed')
    feedContainer?.focus()
  }
}

13. Internationalization (i18n)

13.1 Timestamp đa ngôn ngữ với Intl API

// Không cần ship timezone/locale rules → browser xử lý hết
const date = new Date(post.createdAt * 1000)

// Tiếng Anh
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(date)
// → "May 14, 2026"

// Tiếng Việt
new Intl.DateTimeFormat('vi-VN', { dateStyle: 'long' }).format(date)
// → "14 tháng 5, 2026"

// Tiếng Trung
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'long' }).format(date)
// → "2026年5月14日"

// Relative time
new Intl.RelativeTimeFormat('vi-VN', { numeric: 'auto' }).format(-5, 'minute')
// → "5 phút trước"

13.2 Reaction count: format số đẹp

// Compact notation — tự adapt theo locale
const countFmt = (locale: string) =>
  new Intl.NumberFormat(locale, { notation: 'compact' })

countFmt('en-US').format(103312)  // → "103K"
countFmt('vi-VN').format(103312)  // → "103 N"
countFmt('ja-JP').format(103312)  // → "10万"

13.3 CSS Logical Properties — hỗ trợ Arabic, Hebrew (RTL)

/* ❌ TRÁNH: vỡ layout khi dir="rtl" */
.post-avatar {
  margin-right: 12px;
  float: left;
}

/* ✅ DÙNG: tự flip khi có dir="rtl" trên <html> */
.post-avatar {
  margin-inline-end: 12px; /* right trong LTR, left trong RTL */
  float: inline-start;     /* left trong LTR, right trong RTL */
}

.post-content {
  padding-inline: 16px;    /* padding left + right, tự flip */
  text-align: start;       /* left trong LTR, right trong RTL */
}
<!-- Bài chứa Arabic text trong một feed tiếng Anh -->
<!-- dir="auto": browser tự detect hướng chữ dựa vào ký tự đầu tiên -->
<p dir="auto">
  This is English. وهذا عربي. And back to English.
</p>

14. Telemetry & Observability

14.1 Impression Tracking

Một "impression" được tính khi bài hiển thị ≥50% trong ≥1 giây.

// utils/impressionTracker.ts
import { useEffect, useRef } from 'react'

const trackedPosts = new Set<string>() // Tránh đếm impression nhiều lần

export function useImpressionTracking(postId: string) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const el = ref.current
    if (!el || trackedPosts.has(postId)) return

    let dwellTimer: ReturnType<typeof setTimeout>
    let enterTime: number

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          enterTime = Date.now()
          // Delay 1 giây để tránh count scroll-through
          dwellTimer = setTimeout(() => {
            if (!trackedPosts.has(postId)) {
              trackedPosts.add(postId)
              trackImpression(postId, Date.now() - enterTime)
            }
          }, 1000)
        } else {
          clearTimeout(dwellTimer)
        }
      },
      { threshold: 0.5 } // ≥50% visible
    )

    observer.observe(el)
    return () => {
      observer.disconnect()
      clearTimeout(dwellTimer)
    }
  }, [postId])

  return ref
}

// Batch và gửi khi browser idle hoặc beforeunload
const impressionQueue: { postId: string; dwellMs: number }[] = []

function trackImpression(postId: string, dwellMs: number) {
  impressionQueue.push({ postId, dwellMs })

  // Flush queue khi có 10+ items hoặc khi tab đóng
  if (impressionQueue.length >= 10) {
    flushImpressions()
  }
}

function flushImpressions() {
  if (impressionQueue.length === 0) return
  const batch = impressionQueue.splice(0)
  // sendBeacon: không block page close
  navigator.sendBeacon('/analytics/impressions', JSON.stringify(batch))
}

window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flushImpressions()
})

15. Tóm tắt & Thứ tự trả lời phỏng vấn

15.1 Bảng quick-reference

Chủ đềQuyết địnhLý do
RenderingCSRFeed cá nhân hóa, không cần SEO, long-lived session
NavigationSPAGiữ store/state giữa các route
Store shapeNormalized (ID references)Tránh duplicate, update một chỗ phản ánh khắp nơi
Feed shapeList of post IDs + cursorsFeed thay đổi liên tục, cursor ổn định
PaginationCursor-basedKhông bị skip/duplicate khi có bài mới
Infinite scrollIntersection ObserverKhông block main thread, tốt hơn scroll event
Danh sách dàiVirtualized listGiảm DOM nodes, tăng scroll performance
Reaction UXOptimistic updateCảm giác tức thì, rollback nếu lỗi
Rich textEntity ranges (không HTML)An toàn khỏi XSS, đa platform
URL validationAllowlist http/https/mailtoChặn javascript: và data: URLs
Network thất bạiOutbox + idempotency keyKhông mất action, không duplicate
Feed cũStale bannerGiữ vị trí đọc, user tự quyết định refresh
Cross-tab syncBroadcastChannelTab khác phản ánh thay đổi không cần reload
Metric chínhINP > LCPFeed là long-lived interaction surface
Accessibilityrole=feed, role=article, aria-live=politeScreen reader và keyboard navigation

15.2 Thứ tự nói trong interview (từ quan trọng → detail)

Round 1 — Core (luôn cần đề cập)
├── 1. Clarify requirements (5 phút)
├── 2. CSR + SPA (rendering & navigation)
├── 3. 4 layers: View / Store / Data Access / Server
├── 4. Normalized store + entity design
├── 5. Cursor-based pagination
└── 6. Optimistic updates + idempotency key

Round 2 — Depth (khi interviewer hỏi thêm)
├── 7. Virtualized list (DOM performance)
├── 8. Intersection Observer (infinite scroll)
├── 9. Skeleton loading (perceived performance)
├── 10. Rich text rendering (entity ranges, XSS)
└── 11. Stale feed + cross-tab sync

Round 3 — Senior signals (nếu có thời gian)
├── 12. Offline outbox + Service Worker
├── 13. Live updates (WebSocket vs SSE vs polling)
├── 14. JavaScript loading tiers (Tier 1/2/3)
├── 15. Core Web Vitals (LCP/INP/CLS targets)
└── 16. Accessibility + i18n + telemetry

15.3 Câu trả lời ngắn gọn cho từng câu hỏi điển hình

"Dùng rendering strategy nào?" → CSR cho home feed (signed-in, personalized, long session). SSR cho public post pages và logged-out surfaces.

"Dùng pagination nào?" → Cursor-based. Offset bị trật khi có bài mới insert vào. Cursor dùng post ID làm anchor — stable dù feed thay đổi liên tục.

"Store như thế nào?" → Normalized: postsById, usersById, mediaById riêng biệt. Feed chỉ lưu ordered list of post IDs + cursor metadata. Tránh nested objects.

"Làm sao reaction cảm giác nhanh?" → Optimistic update: cập nhật UI ngay, gửi request song song. Lưu snapshot để rollback nếu server trả lỗi. Server luôn là authoritative khi success.

"List dài performance?" → Virtualization: chỉ render bài trong viewport + overscan. Thay bài ngoài viewport bằng spacer div với đúng height. Facebook và Twitter đều dùng cách này.

"Mất mạng giữa chừng?" → Outbox pattern: lưu mutation vào IndexedDB trước, gửi network sau. Retry khi có mạng lại. Idempotency key để tránh duplicate khi retry.


Tài liệu tham khảo

[session active] Giotto Pham © 2026