Back to Notes
Architecture2 phút đọcOctober 22, 2025

Real-time Price & Stock — Xử lý dữ liệu thay đổi liên tục trên UI

Flash sale, giá thay đổi theo giờ, tồn kho cập nhật mỗi giây — UI của bạn xử lý dữ liệu real-time thế nào? Bài này so sánh Polling, WebSocket, SSE và cách tránh trải nghiệm tệ khi dữ liệu bị stale.

Real-time Price & Stock — Xử lý dữ liệu thay đổi liên tục trên UI

Trong ecommerce, giá có thể thay đổi theo thuật toán dynamic pricing, tồn kho giảm theo từng đơn hàng, flash sale có countdown đếm ngược theo giây. UI của bạn cần phản ánh thực tế này — nhưng làm sai sẽ vừa tốn tài nguyên vừa tạo trải nghiệm tệ.

So sánh: Polling vs WebSocket vs SSE

PollingWebSocketSSE
Độ phức tạpThấpCaoTrung bình
LatencyCao (interval)Thấp nhấtThấp
Server pushKhôngCó (one-way)
ReconnectAutoManualAuto
HTTP/2 supportKhông
Use caseStock check định kỳChat, live bidPrice update, notifications

Khuyến nghị cho ecommerce:

  • Trang product detail: SSE hoặc polling 30s
  • Flash sale page: WebSocket hoặc SSE
  • Cart: chỉ fetch khi user focus lại tab

Vấn đề 1: Stale Stock Warning

Đừng silently cập nhật stock — user cần biết rằng thông tin họ đang xem có thể đã cũ.

tsx
// hooks/useRealtimeStock.ts
export function useRealtimeStock(productId: string) {
  const [stock, setStock] = useState<StockInfo | null>(null);
  const [isStale, setIsStale] = useState(false);
  const lastUpdatedRef = useRef<Date>(new Date());
 
  useEffect(() => {
    const eventSource = new EventSource(`/api/stock/${productId}/stream`);
 
    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data) as StockInfo;
      setStock(data);
      setIsStale(false);
      lastUpdatedRef.current = new Date();
    };
 
    eventSource.onerror = () => {
      // Đánh dấu stale khi mất kết nối
      setIsStale(true);
    };
 
    // Đánh dấu stale nếu không có update quá 2 phút
      const staleTimer = setInterval(() => {
      const age = Date.now() - lastUpdatedRef.current.getTime();
      if (age > 2 * 60 * 1000) setIsStale(true);
    }, 30_000);
 
    return () => {
      eventSource.close();
      clearInterval(staleTimer);
    };
  }, [productId]);
 
  return { stock, isStale };
}
 
// Component usage
function StockBadge({ productId }: { productId: string }) {
  const { stock, isStale } = useRealtimeStock(productId);
 
  return (
    <div className="flex items-center gap-2">
      {stock && (
        <>
          {stock.quantity <= 5 && (
            <span className="text-orange-600 font-medium">
              Chỉ còn {stock.quantity} sản phẩm
            </span>
          )}
          {isStale && (
            <span className="text-gray-400 text-xs" title="Thông tin có thể chưa cập nhật">
              ⚠ Dữ liệu có thể cũ
            </span>
          )}
        </>
      )}
    </div>
  );
}

Vấn đề 2: Flash Sale Countdown — Sync với Server Time

Client clock có thể lệch vài giây so với server. Với flash sale, vài giây là quan trọng.

ts
// lib/time/server-sync.ts
let serverTimeOffset = 0; // ms
 
export async function syncServerTime() {
  const clientBefore = Date.now();
  const { serverTime } = await fetch("/api/time").then((r) => r.json());
  const clientAfter = Date.now();
 
  // Ước tính network latency một chiều
  const latency = (clientAfter - clientBefore) / 2;
  serverTimeOffset = serverTime - clientAfter + latency;
}
 
export function getServerTime(): number {
  return Date.now() + serverTimeOffset;
}
 
// Hook countdown sync với server
export function useFlashSaleCountdown(endTime: number) {
  const [remaining, setRemaining] = useState(() => endTime - getServerTime());
 
  useEffect(() => {
    const timer = setInterval(() => {
      const rem = endTime - getServerTime();
      setRemaining(Math.max(0, rem));
      if (rem <= 0) clearInterval(timer);
    }, 1000);
 
    return () => clearInterval(timer);
  }, [endTime]);
 
  const hours = Math.floor(remaining / 3_600_000);
  const minutes = Math.floor((remaining % 3_600_000) / 60_000);
  const seconds = Math.floor((remaining % 60_000) / 1000);
 
  return { hours, minutes, seconds, isExpired: remaining <= 0 };
}

Vấn đề 3: Price Staleness — Hiển thị Warning thay vì Silent Update

tsx
function PriceDisplay({ productId }: { productId: string }) {
  const { data: currentPrice, dataUpdatedAt } = useQuery({
    queryKey: ["price", productId],
    queryFn: () => fetchCurrentPrice(productId),
    refetchInterval: 60_000, // refetch mỗi phút
    staleTime: 30_000,
  });
 
  const [displayPrice, setDisplayPrice] = useState(currentPrice);
  const [priceChanged, setPriceChanged] = useState(false);
 
  useEffect(() => {
    if (currentPrice && currentPrice !== displayPrice) {
      setPriceChanged(true); // Không update ngay — chờ user acknowledge
    }
  }, [currentPrice, displayPrice]);
 
  return (
    <div>
      <span className="text-2xl font-bold">
        {formatPrice(displayPrice ?? 0)}
      </span>
      {priceChanged && (
        <button
          className="ml-2 text-sm text-blue-600 underline"
          onClick={() => {
            setDisplayPrice(currentPrice);
            setPriceChanged(false);
          }}
        >
          Giá vừa thay đổi → {formatPrice(currentPrice ?? 0)}. Cập nhật?
        </button>
      )}
    </div>
  );
}

Key Takeaways

  • SSE là lựa chọn tốt cho ecommerce real-time — đơn giản hơn WebSocket, tự reconnect
  • Stale indicator: luôn cho user biết khi dữ liệu có thể cũ, đừng hide
  • Countdown timer: sync với server time để tránh drift, đặc biệt quan trọng với flash sale
  • Price change: thông báo rõ ràng thay vì silent update — tránh user bị surprise khi thanh toán

If you found this helpful, leave a like!

Related Posts

Comments available after DB is connected.