Presence and Live Counts — Who is Viewing Right Now

Presence indicators — “5 people are viewing this post” — add social proof and engagement to a content platform. Implementing them with WebSockets requires the server to track the number of active connections in each post’s room and broadcast count updates when users join or leave. The viewer count on the React side is a piece of UI state that the WebSocket updates, making it one of the simplest real-time features to implement given the room infrastructure already built in Chapter 31.

Viewer Count — FastAPI Side

# app/routers/ws.py — room WebSocket with viewer count broadcasts

@router.websocket("/ws/posts/{post_id}/live")
async def post_live_room(
    websocket: WebSocket,
    post_id:   int,
    token:     str = Query(...),
):
    try:
        payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
        user_id = int(payload["sub"])
    except jwt.InvalidTokenError:
        await websocket.close(code=1008, reason="Invalid token")
        return

    room = f"post:{post_id}"
    await room_manager.join(room, user_id, websocket)

    # Broadcast updated viewer count to ALL room members on join
    await room_manager.broadcast_to_room(room, {
        "type":    "viewer_joined",
        "viewers": room_manager.room_size(room),
    })
    # Also send the current count to the just-joined user immediately
    await websocket.send_json({
        "type":    "room_joined",
        "post_id": post_id,
        "viewers": room_manager.room_size(room),
    })

    try:
        while True:
            data = await websocket.receive_json()
            if data.get("type") == "ping":
                await websocket.send_json({"type": "pong"})

    except WebSocketDisconnect:
        room_manager.leave(room, user_id, websocket)
        # Broadcast updated count after disconnect
        if room_manager.room_size(room) > 0:
            await room_manager.broadcast_to_room(room, {
                "type":    "viewer_left",
                "viewers": room_manager.room_size(room),
            })
Note: The viewer count from an in-process RoomManager is accurate only within a single FastAPI process. With multiple Uvicorn workers, each worker has its own room manager and its own count. User A on worker 1 and User B on worker 2 both see viewer count = 1 (their own local count), not 2. For accurate cross-process counts, use Redis to store the count: increment on join, decrement on leave, broadcast via Redis Pub/Sub. For a small blog application, single-process deployment is acceptable.
Tip: Display the viewer count only when it is greater than 1 — “1 person viewing this” is you, so it is not socially interesting. The check {viewerCount > 1 && <p>{viewerCount} people viewing this</p>} makes the element appear only when there is genuinely an audience, which adds social proof without noise when the user is alone on the page.
Warning: Viewer counts can be inflated by bots, search engine crawlers, or a single user refreshing repeatedly. Do not use raw WebSocket viewer counts for analytics or monetisation decisions — they measure “active WebSocket connections”, not unique human users. For meaningful analytics, use a dedicated analytics service (Plausible, Fathom, or a custom implementation) that tracks unique sessions and filters out known bots.

Viewer Count Component

import { useAuthStore }  from "@/stores/authStore";
import { WS_STATUS }     from "@/hooks/useWebSocket";

function ViewerCount({ postId }) {
    const isLoggedIn = useAuthStore((s) => Boolean(s.user));

    const { viewerCount, connectionStatus } = useLiveComments(postId);

    if (!isLoggedIn || connectionStatus !== WS_STATUS.OPEN) return null;
    if (viewerCount <= 1) return null;

    return (
        <div className="flex items-center gap-1.5 text-sm text-gray-400">
            {/* Animated green dot indicating live connection */}
            <span className="relative flex h-2 w-2">
                <span className="animate-ping absolute inline-flex h-full w-full
                                 rounded-full bg-green-400 opacity-75" />
                <span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
            </span>
            {viewerCount} people viewing
        </div>
    );
}

// Viewer count that resets gracefully on WebSocket restart
function ReliableViewerCount({ postId }) {
    const { viewerCount, connectionStatus } = useLiveComments(postId);

    // Show last known count during reconnection (don't flash to 0)
    const [displayCount, setDisplayCount] = useState(0);

    useEffect(() => {
        if (connectionStatus === WS_STATUS.OPEN) {
            setDisplayCount(viewerCount);
        }
        // Don't reset to 0 during reconnection — keep last known value
    }, [viewerCount, connectionStatus]);

    if (displayCount <= 1) return null;

    return (
        <p className="text-sm text-gray-400">
            {displayCount} people viewing
            {connectionStatus === WS_STATUS.RECONNECTING && (
                <span className="ml-1 opacity-50">(reconnecting…)</span>
            )}
        </p>
    );
}

Common Mistakes

Mistake 1 — Showing viewer count = 1 (you are alone, not useful)

❌ Wrong — shows “1 person viewing this” when the user is alone on the page.

✅ Correct — only show when viewerCount > 1.

Mistake 2 — Resetting count to 0 during reconnection (flickering)

❌ Wrong — briefly shows 0 or hides the count during a 1-2 second reconnect.

✅ Correct — keep the last known count displayed during reconnection.

🧠 Test Yourself

The FastAPI server restarts (deploys a new version). All WebSocket connections are dropped. The viewer count on the React side resets to 0. Users see “0 people viewing” briefly before reconnecting. How do you prevent this flicker?