A view count is the first number a visitor trusts. It tells them whether a video is worth thirty seconds or thirty minutes of their time. At TopVideoHub, that number moves fast. A trending clip can rack up 40,000 views in ten minutes. If the counter on the page freezes, the room feels empty. Users bounce.
Getting that number to the browser sounds trivial. It is not. The first solution most teams reach for is polling. It is easy to wire up and it works fine in staging. But staging lies.
When Polling Becomes a DDoS Against Yourself
The TopVideoHub team built a simple JavaScript poller. It fetched the latest view count every five seconds. In a test environment with three browsers open, it looked great. In production, it cratered the platform.
Eight thousand concurrent viewers refreshing every five seconds generated 1,600 requests per second. Every request drilled into the database. Replication lag spiked. Read replicas groaned. Cache layers got bypassed. The team was not serving video. They were serving self-inflicted load.
Polling is innocent until it is not. For low-traffic dashboards or admin panels, it is fine. For a viral video page, it is a ticking bomb. The team needed a persistent pipe from server to browser, but they did not need the complexity of a full-duplex protocol.
Why SSE Fits One-Way Pipes
Server-Sent Events (SSE) is built for exactly this shape of problem: the server has data, and the browser only needs to listen.
Unlike WebSockets, SSE rides on plain HTTP. That matters more than it sounds. You do not need new proxy rules, upgrade headers, or load-balancer gymnastics. If your server speaks HTTP/1.1 or HTTP/2, SSE works. Debugging is painless because the stream is just text. You can point curl at the endpoint and watch numbers scroll by in real time, which beats guessing why a binary socket frame went sideways.
The browser handles the tedious parts for free. If the connection drops, SSE reconnects automatically with the Last-Event-ID header so the server knows where to resume. The API surface in JavaScript is tiny: create an EventSource, attach an onmessage handler, and you are done.
Caching Is the Real Architecture
The biggest architectural mistake in live counters is treating every browser connection as a reason to query the database. If 8,000 people watch the same video, running 8,000 queries every two seconds is madness. Your database will not survive viral traffic.
TopVideoHub solved this with APCu, PHP’s in-memory opcode and user cache. The flow is simple. A background process—or a lightweight endpoint hit on a timer—writes the current view count into APCu once every two seconds. The SSE endpoint, which thousands of browsers may be holding open, reads exclusively from APCu.
The result: the database takes one hit every two seconds, no matter how many viewers are tuned in. The cache becomes the shock absorber. APCu is not exotic. It ships with PHP, lives in shared memory, and reads faster than any network round-trip. For a single number that changes frequently but not instantaneously, it is the right tool.
If you are not running APCu, Redis or Memcached work too. The principle stays the same. Separate the hot read path from the database.
Convincing PHP, LiteSpeed, and Cloudflare to Stream
PHP wants to finish and go home. Web servers want to buffer output and deliver a neat response. SSE needs the opposite: a connection that stays open, flushing bytes as they arrive. Without care, your "stream" arrives as a single blob thirty seconds later, defeating the purpose.
Here is how TopVideoHub kept the pipe unclogged.
Kill output buffering. At the start of the SSE script, disable every layer of buffering PHP might have enabled. Call ob_end_flush() if a buffer is active, and turn off implicit flushing with ob_implicit_flush(true) after your headers are sent.
Tell proxies to back off. Send the X-Accel-Buffering: no header. Nginx listens to it. LiteSpeed listens to it. It signals that the response should not be buffered or compressed into a cacheable lump.
Set a short lifetime. Each SSE connection ties up a PHP worker. TopVideoHub caps streams at 55 seconds. When the timer hits, the server sends a final comment, closes the stream, and the browser reconnects automatically. That reconnection lands on a fresh worker, preventing any single process from squatting forever.
Gửi ping để duy trì kết nối. Hãy gửi một dòng chú thích—chẳng hạn như : ping—mỗi hai mươi giây một lần. Các dòng chú thích trong SSE sẽ bị trình xử lý tin nhắn của trình duyệt bỏ qua, nhưng chúng giúp duy trì kết nối TCP luôn hoạt động. Các bộ cân bằng tải (load balancers) và CDN thường ngắt các kết nối im lặng sau 30 hoặc 60 giây. Một ký tự xuống dòng đơn giản sẽ giúp bạn tránh được tình trạng đó.
Tôn trọng tab của người dùng. Khi người truy cập thu nhỏ hoặc ẩn tab, hãy thoát ra. Hãy lắng nghe sự kiện visibilitychange trong trình duyệt và gọi eventSource.close(). Máy chủ cũng nên phát hiện việc ngắt kết nối từ phía client và kết thúc vòng lặp. PHP có thể kiểm tra connection_aborted() bên trong một vòng lặp. Đừng để các kết nối "ma" (ghost connections) làm lãng phí worker cho những người đã rời đi từ mười phút trước.
Giới hạn cứng: PHP Workers
SSE trong PHP rất rõ ràng về các giới hạn của nó. Mỗi kết nối SSE đang mở sẽ tiêu tốn một PHP worker. Nếu pool của bạn có 100 worker, bạn chỉ có tối đa 100 stream. Chấm hết. Không có giải pháp thay thế bất đồng bộ (async) nào khi bạn đang nằm trong mô hình tiến trình (process model) của Apache hoặc PHP-FPM. Bạn có thể tinh chỉnh pm.max_children, nhưng bộ nhớ và CPU mới là yếu tố thiết lập ranh giới thực sự.
Giới hạn đó sẽ trở nên rất khắc nghiệt nếu bạn cũng đang sử dụng worker cho việc tải trang thông thường, gọi API và tạo tài nguyên (asset generation). Hãy giám sát mức độ bão hòa worker của bạn một cách cẩn thận. Nếu endpoint SSE của bạn bắt đầu bị xếp hàng (queuing) vì tất cả worker đều bị khóa trong các stream kéo dài 20 phút, toàn bộ trang web của bạn sẽ bị chậm lại.
Khi các con số không còn đáp ứng được nữa, hãy chuyển đổi. Go thường là bước tiếp theo, mặc dù Rust, Node.js hoặc Erlang cũng có thể đóng vai trò tương tự. Các goroutine của Go chính là chìa khóa. Một goroutine chỉ tốn vài kilobyte. Bạn có thể duy trì hàng chục nghìn stream trên phần cứng khiêm tốn mà không gặp khó khăn gì. Logic cốt lõi vẫn giữ nguyên—đọc từ cache, ghi vào socket—nhưng runtime sẽ chuyển từ các tiến trình nặng nề sang các luồng (threads) nhẹ nhàng.
Tuy nhiên, đừng bắt đầu từ đó. PHP có thể giúp bạn đi xa một cách đáng ngạc nhiên. Hãy xác thực sản phẩm trước. Khi trang chỉ số (metrics) cho thấy sự cạn kiệt worker thay vì quá tải cơ sở dữ liệu, nghĩa là bạn đã vượt quá khả năng của stack hiện tại. Đó là một vấn đề tốt.
Bài học rút ra
Các bộ đếm trực tiếp (live counters) không chỉ là về công nghệ thuần túy. Chúng là về việc bảo vệ cơ sở dữ liệu của bạn khỏi chính người dùng của mình. Hãy bắt đầu với SSE vì nó đơn giản hơn vẻ ngoài của nó. Hãy cache một cách quyết liệt giữa stream và cơ sở dữ liệu để số lượng kết nối không biến thành số lượng truy vấn. Hãy giám sát giới hạn worker của bạn như một con diều hâu. Và hãy bắt đầu một cách đơn giản. PHP là đủ cho đến khi nó không còn đủ nữa, và đến lúc đó bạn sẽ biết chính xác lý do tại sao mình cần viết lại.
Cho dự án tiếp theo của bạn:
- Sử dụng SSE khi dữ liệu chỉ chảy theo một chiều, từ máy chủ đến trình duyệt.
- Đặt một lớp cache phía trước cơ sở dữ liệu. Một truy vấn sau mỗi vài giây sẽ tốt hơn hàng nghìn truy vấn.
- Giới hạn các kết nối SSE dưới một phút và để trình duyệt tự kết nối lại.
- Đóng các stream khi ẩn tab. Đừng lãng phí các worker đang hoạt động cho các kết nối nhàn rỗi.
- Giám sát việc sử dụng PHP worker. Khi bạn đạt đến giới hạn tối đa, hãy chuyển đổi lớp streaming sang Go.
