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
| Polling | WebSocket | SSE | |
|---|---|---|---|
| Độ phức tạp | Thấp | Cao | Trung bình |
| Latency | Cao (interval) | Thấp nhất | Thấp |
| Server push | Không | Có | Có (one-way) |
| Reconnect | Auto | Manual | Auto |
| HTTP/2 support | Có | Không | Có |
| Use case | Stock check định kỳ | Chat, live bid | Price 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.