Get started
Real-time dashboard sync

Real-time dashboard sync

pdf4.dev now updates every open dashboard tab the moment an AI agent or an API call writes a template, a component, or a PDF render log. Inside the architecture: SSE, an in-process EventEmitter bus, and the Railway and Next.js gotchas we had to work around.

9 min read

Ask Claude Desktop to draft an invoice template for you. Switch back to the PDF4.dev dashboard without touching anything. The new card is already there, in the grid, with its preview iframe warming up. Open the logs page in a second tab and fire a curl render from your terminal: a row slides in, the counters tick up, and the sidebar connection dot pulses green. That is real-time dashboard sync, the feature we are shipping today.

What changed for users

Four surfaces of the PDF4.dev dashboard now update on their own. No refresh, no polling. Each one reacts to the same stream of events published by the server whenever a write happens anywhere in your organization.

Templates grid

Before today the template grid was a snapshot of whatever was in the database when you loaded the page. If an agent running in Claude Desktop created a template through the MCP create_template tool, you had to reload to see it.

Now the new card appears in place. Deletions fade out. Renames update the title without a flicker. The same grid that was a snapshot two weeks ago is now a live view of your organization's templates.

Logs page

The logs page used to refresh on a 10 second timer. Fine for small volumes, noisy once you started hammering the render endpoint from a script.

Now every call to POST /api/v1/render publishes a log.created event. The new row slides in at the top of the table with a short purple pulse highlight, and the four stats cards (renders, success rate, average duration, matching count) update their numbers in place. A curl loop from your terminal is enough to turn the page into a dashboard worth leaving on a second monitor.

API keys

API key management lives under settings. If you opened two browser tabs on the settings page and created a key in one, the other tab still showed the old list until you refreshed. That was confusing when the agent path started creating keys.

Now the second tab catches up instantly. The new key appears with its prefix and permission label the moment it lands in the database.

Template and component editors

The editors are the place where silent overwrites would hurt. If you are mid-edit on a template and your colleague updates the same template from another tab, PDF4.dev handles it in two ways:

  • If your local buffer has no unsaved changes, an info toast appears and the editor refetches the new state.
  • If you have unsaved changes, a warning toast shows up with a Reload action. Nothing is touched until you click it. Your draft wins by default.

Deletions redirect you away from the editor with a clear toast, so you never edit against a tombstone.

The sidebar footer also has a new connection status dot: green when the stream is live, amber while reconnecting, red after repeated failures. It is small but it removes any doubt about whether the data you see is fresh.

How it works

The interesting part is the plumbing. The feature sits on three pieces: an in-process event bus, a hardened SSE route, and a client hook that knows about Railway's quirks.

Loading diagram…

The event bus

Every write path inside the app publishes to a process-local bus. The bus is a thin wrapper around Node's EventEmitter, scoped per organization so events never leak across tenants. It pins setMaxListeners(1000) to keep Node's leak detector armed at a realistic ceiling, and it stashes itself on globalThis so HMR in development does not spawn a second bus on every file save.

Events are strongly typed as a discriminated union. All nine variants live in one file, and every route that writes to the database publishes the matching variant before returning:

export type LiveEvent =
  | { type: "template.created"; template: { id: string; name: string; slug: string; updated_at: string } }
  | { type: "template.updated"; template: { id: string; name: string; slug: string; updated_at: string } }
  | { type: "template.deleted"; id: string }
  | { type: "component.created"; component: { id: string; name: string; type: "header" | "footer" | "block"; updated_at: string } }
  | { type: "component.updated"; component: { id: string; name: string; type: "header" | "footer" | "block"; updated_at: string } }
  | { type: "component.deleted"; id: string }
  | { type: "api_key.created"; apiKey: { id: string; name: string; prefix: string; permission: "full_access" | "render_only"; created_at: string } }
  | { type: "api_key.deleted"; id: string }
  | { type: "log.created"; log: { id: string; template_id: string | null; template_name: string | null; status: "success" | "error"; duration_ms: number; size_bytes: number | null; error: string | null; created_at: string } };

In-process is the right shape for a single Railway instance. When PDF4.dev scales horizontally we swap the EventEmitter for Redis pub/sub without changing the LiveEvent contract or the client. Today's design keeps the swap cheap.

The SSE route

GET /api/v1/events is the pipe between the bus and every open dashboard tab. It runs on the Node runtime, authenticates with the normal session helper, and emits an Authorization-free text/event-stream response. Three headers matter: Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, and X-Accel-Buffering: no so Railway's edge and any intermediate nginx do not buffer frames.

A 15 second keepalive comment keeps the stream alive through idle timeouts. Every enqueue is guarded by a closed flag plus a try/catch, because Next.js App Router has a known crash where writing to a ReadableStreamDefaultController after a client abort throws ResponseAborted and can take the Node process down with it (see vercel/next.js#56529).

Cleanup order is the subtle part. Late callbacks from the bus must not race a closed controller, so we unsubscribe from the bus FIRST, then clear the timers, then close the controller:

const cleanup = () => {
  if (closed) return;
  closed = true;
  // Unsubscribe first so no late bus callback can reach enqueue
  // after controller.close() has run.
  unsubscribe();
  clearInterval(keepalive);
  clearInterval(sessionRecheck);
  clearTimeout(maxDurationTimer);
  try {
    controller.close();
  } catch {
    // already closed
  }
};

The Railway 5 minute cap

Railway enforces a hard 5 minute ceiling on every HTTP request, SSE included. Keepalive comments do not extend it, the proxy drops the socket at the 5 minute mark regardless. We learned this the direct way while testing feat/live-sync, and the Railway station thread on the subject is here.

The fix is to never let Railway be the one to close the stream. The server proactively closes at 4 minutes, sending a final : reconnect comment and running the cleanup path. The client rotates the EventSource at 3m45s, 15 seconds before the server does, so the client always owns the reconnect window and never loses a frame to a surprise mid-stream drop.

The client hook

useLiveEvents wraps EventSource and exposes a status value for the sidebar dot. Reconnects use exponential backoff from 1 second up to 30 seconds, with ±30% jitter so every tab on the planet does not pile onto the same reconnect window after a shared outage. The callback is tracked through a ref so callers can pass inline closures without tearing down the socket on every render.

Security

Live streams are a pleasant feature and a quiet attack surface. Four things keep it honest:

  • Per-user cap of 10 concurrent connections, enforced in a Map<string, number>. Above that the route returns 429 with a Retry-After: 30 header. Browsers cap themselves at around 6 sockets per origin anyway, and the ceiling is there to stop a scripted client with a valid session from pinning thousands of listeners on the shared bus.
  • A session recheck runs every 30 seconds inside the stream. If your session is revoked or you leave the organization, your streams tear down within one interval instead of lingering until the 4 minute rotation.
  • Events are partitioned by organizationId at the bus level. Cross-org leakage is not a code review concern, it is a data structure property.
  • Error messages on log events flow through lib/errors/sanitize.ts before hitting the database. Playwright container paths and stack traces never reach other dashboard tabs, only the clean error codes users are allowed to see.

What is next

Live sync is horizon 1 of the Phase 5 collaboration track in AI-STRATEGY.md. Two larger horizons sit behind it.

Horizon 2 is awareness cursors inside the template editor: colored carets showing where every collaborator is typing, who is viewing a given template, and who locked the Monaco buffer. Awareness does not need a CRDT, it rides on the same SSE bus with a small presence channel.

Horizon 3 is full collaborative editing. That one needs conflict-free merges, and Yjs bolted onto the existing bus is the cheap on-ramp: a yjs.update event variant carrying binary deltas, applied on every client's shared doc. If collaborative editing ever becomes a first-class objective for PDF4.dev, Convex and Zero by Rocicorp are on our radar as the end state, but we will only rewrite the data layer when the pain justifies it.

No dates on any of this. Horizon 1 shipping today unlocks the rest, and we would rather see how teams use a live dashboard before deciding which horizon to build next.

Try it

Log into PDF4.dev, open the dashboard in two browser tabs, and ask Claude Desktop to create a new invoice template through the MCP server. Watch the card appear in both tabs at once. Then run a curl render from your terminal against POST /api/v1/render and flip to the logs page to see the row land live. MCP setup for Claude, Cursor, Windsurf, and VS Code is documented on the AI integration page.

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.