· Rong Zhu · 8 min read

How We Built a Self-Hosted Session Replay Alternative to FullStory

Why we built PinConsole, the architecture decisions we made, and how you can deploy it in 5 minutes.

EngineeringArchitectureSelf-HostedOpen Source

The Problem with SaaS Session Replay

FullStory is a fantastic product. Its session replay is polished, its AI-powered search is impressive, and its heatmaps and rage-click analytics are best-in-class. But for many teams, FullStory (and similar tools like Hotjar, LogRocket, and Smartlook) share a fundamental limitation: they are SaaS-only.

This means your session data — every click, scroll, and input your users make — is transmitted to and stored on a third-party cloud. For organizations with strict data sovereignty requirements (GDPR, SOC 2, HIPAA, or China's Personal Information Protection Law), this creates a compliance headache. The enterprise on-premise plans exist, but they come with enterprise pricing: FullStory Business starts at $500/month and scales quickly.

Beyond cost and compliance, there's another gap: none of these tools offer co-browsing or real-time visitor monitoring. If you need to see what a visitor is doing right now and proactively help them, you need a separate tool like Cobrowse.io or Upscope — another subscription, another integration.

We built PinConsole to solve all of these problems in one self-hosted binary.

Why Go?

The decision to build the backend in Go was driven by three requirements:

1. Single-binary deployment — we wanted users to deploy PinConsole with nothing more than Docker Compose. Go compiles to a statically-linked binary with zero runtime dependencies.

2. Concurrency — session replay involves hundreds of concurrent WebSocket connections. Go's goroutine model handles this gracefully without the complexity of thread pools or event loops.

3. Performance — the event pipeline (receive rrweb events → store in MinIO → broadcast to admin) needs to be fast and predictable. Go's runtime delivers consistent latency under load.

We target 500 concurrent WebSocket connections per room as our baseline — that's one operator serving a mid-size e-commerce site's traffic in real time.

Architecture Overview

PinConsole follows a hub-and-spoke architecture. All traffic flows through the central server — there are no P2P connections, no WebRTC, no third-party relay.

The stack is straightforward:

• PostgreSQL — metadata storage (sites, sessions, users, operator accounts) • Redis — presence tracking, rate limiting, hot cache • MinIO — rrweb event stream storage + selective screenshots • Go server — Gin HTTP router + coder/websocket hub

The admin frontend is a Vue 3 SPA embedded via Go's //go:embed directive. The visitor SDK is a TypeScript library served from the same origin. Everything — server, admin UI, SDK — is a single binary.

docker compose up -d
# PostgreSQL, Redis, and MinIO start as sidecars
# The PinConsole binary binds to :8080
# Admin UI: https://app.yourdomain.com
# SDK: https://app.yourdomain.com/sdk.js

DOM Capture with rrweb

For session recording, we use rrweb — the same open-source library that powers many production session replay tools. rrweb captures DOM mutations as a serialized event stream: full DOM snapshots at the start, then incremental mutations (click, scroll, input, resize, etc.) as they happen.

Why rrweb over alternatives?

• It's battle-tested — used by projects with millions of recorded sessions • The event format is well-specified and serializable (JSON) • It handles iframes, shadow DOM, canvas/WebGL snapshots (with capture plugins) • It's MIT-licensed — compatible with our AGPL-3.0 stack

We did need to build some custom infrastructure around rrweb. The raw event stream from a busy site can generate hundreds of events per second. Rather than storing each event as an individual object, we batch events into time-windowed chunks and store them as MinIO objects. This reduces storage operations by 10-100x and makes replay sequential reads fast.

For privacy, we implemented a selective capture mode. Instead of recording the full DOM, operators can configure CSS selectors to mask sensitive elements (password fields, credit card inputs, PII containers). The masked data is never sent to the server — it's stripped at the SDK level before transmission.

Real-Time WebSocket Hub

The real-time layer is the heart of PinConsole's co-browsing and live monitoring features. We chose coder/websocket over alternatives for a simple reason: it's a minimal, idiomatic Go library with no global state, no implicit goroutine spawning, and a clean API.

The hub pattern works like this:

• Each site has a "room" identified by site ID • Visitor SDKs connect to their site's room • Operators connect to the same room • Messages are routed: visitor → hub → operator (and vice versa for co-browsing)

For live monitoring, the SDK sends a DOM snapshot every 100ms (throttled) plus incremental events. The operator's admin panel renders this as a live preview. For co-browsing, operator actions (click, scroll, form fill) are serialized as rrweb mutation events and forwarded to the visitor's browser — with a 300ms debounce to prevent conflicts.

One detail that mattered: we do NOT broadcast all events to all operators. Each visitor stream is 1:1 locked to one operator (claim/release pattern). This avoids the overhead of fan-out and ensures clear ownership — no two operators fighting to control the same co-browsing session.

Event Storage with MinIO

Session replay data is write-heavy and append-only. A 10-minute session can generate 50,000+ rrweb events (roughly 2-5MB of serialized JSON). Storing this in PostgreSQL would work but isn't ideal — blob storage is cheaper, faster for sequential reads, and doesn't compete with your OLTP workload.

MinIO (S3-compatible object storage) is a natural fit:

• Each session's events are stored as a sequence of objects in a "session" prefix • The first object is the full DOM snapshot (snapshot.json) • Subsequent objects are 5-second time-windowed event batches (events-{timestamp}.json) • Screenshot mode: when canvas/WebGL/cross-origin iframe is detected, a 1fps WebP screenshot (quality 70) is stored alongside the event stream

For replay, the server reads the event objects in order and streams them to the admin UI. Gap detection handles missing batches gracefully — if a batch is unavailable, replay shows a "buffering" indicator and skips ahead.

We also implemented a retention policy system: operators can set TTL per site (7/30/90 days or forever). MinIO's lifecycle policies handle the actual deletion — we just set the object tags.

The SDK: Tiny, Modular, and Self-Hosted

The visitor SDK is built with TypeScript + Vite. It ships as a single /sdk.js file served from the same origin as the PinConsole admin panel. No CDN, no third-party script loaders — if your server is reachable, the SDK works.

The SDK weighs approximately 15KB gzipped (compared to FullStory's ~50KB+). We achieved this by:

• Only bundling the rrweb core recorder (not the replayer — that's server-side) • Using a lightweight WebSocket client instead of HTTP long-polling • Making features opt-in via the SDK configuration object

The SDK lifecycle:

1. Page loads → SDK initializes and establishes a WebSocket connection 2. rrweb starts recording DOM mutations with selective masking 3. Events are buffered locally and flushed every 200ms 4. If the connection drops, events are queued and replayed on reconnect 5. When the visitor leaves, a "session-end" marker is sent

We deliberately avoid any form of user identification (no fingerprinting beyond what's needed for bot detection). The session is identified by a server-generated UUID, not by tracking the visitor across sites.

Security and Anti-Bot Design

Since PinConsole is self-hosted and designed for production traffic, we built defense-in-depth from day one.

The anti-bot system operates at four layers:

1. Rate limiting — per-IP and per-session event rate caps (configurable) 2. User-Agent blacklist — known bot UAs are rejected at the HTTP level 3. Behavioral analysis — the server monitors event patterns. A session that generates 1000 clicks/second is probably a bot, not a human 4. TLS fingerprint — passive JA3 fingerprinting of incoming WebSocket connections

For the admin panel, all authenticated routes require an HttpOnly session cookie. WebSocket connections for operators use the same cookie for authentication — no token in the URL, no token in the query string.

We also added a Content Security Policy that's strict by default: only the self-origin can load scripts, only challenges.cloudflare.com is allowed for Turnstile (if enabled), and inline styles are hashed.

FullStory Feature Parity: What We Have vs What's Coming

We're honest about where we stand:

✓ Session replay (rrweb-based, pixel-perfect) ✓ Real-time visitor monitoring (DOM + screenshot) ✓ Two-way co-browsing (click, scroll, form fill, navigation) ✓ Proactive popup chat ✓ Anti-bot protection ✓ Self-hosted (your servers, your data) ✓ AGPL-3.0 (free) with commercial license available

△ Heatmaps and rage clicks — on the roadmap (est. Q3 2026) △ AI-powered session search — on the roadmap △ Funnel and conversion analytics — on the roadmap

✗ Native mobile SDKs — not in v1 scope. PinConsole v1 targets web only. ✗ Custom domain per site — on the post-v1 backlog

If you need FullStory's advanced analytics layer today, PinConsole works great as the core session replay and monitoring platform while you keep FullStory for heatmaps. But for teams that prioritize data sovereignty, cost control, and open source — PinConsole already covers the critical use cases.

Deploying in Production

Deploying PinConsole is intentionally boring:

1. Clone the repository 2. Configure .env with your secrets 3. docker compose up -d 4. Add the SDK snippet to your website

For production, we recommend:

• PostgreSQL 15+ with connection pooling (PgBouncer recommended for 100+ concurrent sites) • Redis 7+ for presence and rate limiting • MinIO in distributed mode for HA object storage • A reverse proxy (Nginx/Caddy) for TLS termination • Regular backups of PostgreSQL and MinIO buckets

The single-binary model means upgrades are atomic: pull the new image, restart, done. Database migrations run automatically on startup. No package managers, no runtime upgrades, no dependency conflicts.

git clone https://github.com/iannil/pinconsole
cd pinconsole
cp .env.example .env
# Edit .env with your secrets
docker compose up -d

Try PinConsole Today

Self-hosted in 5 minutes. AGPL-3.0. Your data, your servers.