Build a Pure H5 PDF Screen‑Sharing App with PDF.js and WebSocket

This article details a lightweight pure‑HTML5 solution for synchronizing PDF documents across participants using PDF.js for rendering and a FastAPI‑based WebSocket layer for real‑time state broadcasting, covering architecture, workflow, code snippets, and synchronization logic.

xkx's Tech General Store
xkx's Tech General Store
xkx's Tech General Store
Build a Pure H5 PDF Screen‑Sharing App with PDF.js and WebSocket

Problem Background

Limited budget, small project scale, and modest team expertise often prevent the use of complex streaming solutions for screen‑sharing PDF documents. The implementation relies solely on HTML5, PDF.js and WebSocket to provide a low‑cost, synchronized PDF sharing solution.

Core Technologies

Backend : FastAPI + WebSocket + Python – manages WebSocket connections, maintains meeting state, handles PDF uploads, and broadcasts synchronization commands.

Frontend : PDF.js – parses PDF URLs, renders pages onto a canvas, and supports paging and zooming.

Communication : WebSocket with custom reconnection/heartbeat and debounce/throttle – ensures stable long‑lived connections and avoids performance degradation from frequent command transmission.

Functional Flow

Session initialization : Presenter and viewers join a room identified by a shared ID (e.g., share). The backend pushes an INIT_STATE message containing the current sharing status and participant count. If sharing is already active, viewers immediately load the presenter’s PDF URL and render it.

Presenter uploads PDF : The presenter selects a local PDF, uploads it to the backend, which generates a URL. The presenter previews the document locally; if sharing is active, the URL and initial state are broadcast to all viewers.

Sharing mode operation : Presenter actions (page turn, zoom, scroll) are debounced/throttled and sent as STATE_UPDATE messages. The backend updates the shared state and broadcasts it to all non‑presenter clients, which apply the new page number, zoom level and scroll position.

Closing sharing mode / connection loss : When the presenter disables sharing, the backend broadcasts SYNC_MODE_CHANGED. Viewers unlock their UI, display a waiting message, and the backend cleans up the connection. If the presenter disconnects, sharing mode terminates automatically.

Key Implementation Details

Presenter – toggling sync mode

function toggleSyncMode() {
    const newState = !isSyncMode; // toggle
    wsManager.send({
        type: 'TOGGLE_SYNC',
        enabled: newState
    });
    if (newState && pdfManager.pdfDoc) {
        syncState(); // immediately sync current PDF state
    }
}

Backend handling of TOGGLE_SYNC

elif msg_type == "TOGGLE_SYNC":
    # switch sync mode
    await manager.toggle_sync_mode(meeting_id, client_id, data.get("enabled", False))

toggle_sync_mode method

async def toggle_sync_mode(self, meeting_id: str, client_id: str, enabled: bool):
    if self.controllers.get(meeting_id) != client_id:
        return False
    state = self.states.get(meeting_id)
    if state:
        state.is_sync_mode = enabled
        state.timestamp = datetime.now().timestamp()
        await self.broadcast(meeting_id, {
            "type": "SYNC_MODE_CHANGED",
            "is_sync_mode": enabled,
            "state": asdict(state)
        })
    return True

Synchronizing presenter state

function syncState() {
    if (!wsManager || !pdfManager.pdfDoc) return;
    const state = pdfManager.getState();
    state.document_url = currentDocumentUrl;
    wsManager.send({
        type: 'STATE_UPDATE',
        state: state // includes page_number, scale, scroll_x, scroll_y, etc.
    });
    updatePageDisplay(state.page_number, state.total_pages);
    updateScaleDisplay(state.scale);
}

pdfManager.getState()

getState() {
    return {
        page_number: this.currentPage,
        total_pages: this.totalPages,
        scale: this.scale,
        scroll_x: this.container.scrollLeft,
        scroll_y: this.container.scrollTop
    };
}

Backend handling of STATE_UPDATE

if msg_type == "STATE_UPDATE":
    await manager.update_state(meeting_id, client_id, data.get("state", {}))

update_state method

async def update_state(self, meeting_id: str, client_id: str, data: dict):
    if self.controllers.get(meeting_id) != client_id:
        return False
    state = self.states.get(meeting_id)
    if not state: return False
    for key, value in data.items():
        if hasattr(state, key):
            setattr(state, key, value)
    state.timestamp = datetime.now().timestamp()
    if state.is_sync_mode:
        await self.broadcast(meeting_id, {
            "type": "STATE_UPDATE",
            "state": asdict(state)
        }, exclude=client_id)  # do not send back to presenter
    return True

Viewer – handling synchronization messages

wsManager.on('SYNC_MODE_CHANGED', async (data) => {
    isSyncMode = data.is_sync_mode;
    updateSyncUI(isSyncMode);
    if (isSyncMode && data.state) {
        if (data.state.document_url && data.state.document_url !== currentDocumentUrl) {
            await loadDocument(data.state.document_url);
        }
        await pdfManager.applyState(data.state);
        updatePageDisplay(data.state.page_number, data.state.total_pages);
        updateScaleDisplay(data.state.scale);
    }
});

wsManager.on('STATE_UPDATE', async (data) => {
    if (!isSyncMode) return;
    const state = data.state;
    if (state.document_url && state.document_url !== currentDocumentUrl) {
        await loadDocument(state.document_url);
    }
    await pdfManager.applyState(state);
    updatePageDisplay(state.page_number, state.total_pages);
    updateScaleDisplay(state.scale);
});

pdfManager.applyState(state)

async applyState(state) {
    if (state.scale && state.scale !== this.scale) {
        this.scale = state.scale;
    }
    if (state.page_number && state.page_number !== this.currentPage) {
        await this.renderPage(state.page_number);
    } else if (state.scale) {
        await this.renderPage(this.currentPage);
    }
    if (this.container) {
        if (state.scroll_x !== undefined) this.container.scrollLeft = state.scroll_x;
        if (state.scroll_y !== undefined) this.container.scrollTop = state.scroll_y;
    }
}

Result and Advantages

The implementation transmits only synchronization commands and the PDF URL, resulting in minimal bandwidth consumption. Role permissions are explicit, WebSocket long‑connections guarantee real‑time updates, and the entire solution runs on a pure HTML5 stack without requiring complex media infrastructure.

Flow diagram
Flow diagram
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

WebSocketFastAPIHTML5PDF.jsReal-time SyncScreen Sharing
xkx's Tech General Store
Written by

xkx's Tech General Store

Code with the left hand, enjoy with the right; a keystroke sweeps away worries.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.