June 4, 2026

Real-Time PDF Progress Streaming with SSE and X-Job-Id

sserealtimeapistreamingpdf

In our earlier guide on polling vs webhooks for async PDF status tracking, we covered the two most common approaches to monitoring async PDF jobs. This article introduces the third option — Server-Sent Events (SSE) — which FUNBREW PDF API added in the May 31, 2026 release.

SSE is the right choice when you need to stream live progress updates directly to a browser UI. No public webhook endpoint required, no repeated polling calls — just a persistent HTTP stream that pushes phase-by-phase progress as it happens.

Polling vs Webhook vs SSE

Polling Webhook SSE
Real-time updates Delayed by interval Yes Yes
Requires public endpoint No Yes No
Usable directly from browser Yes No Yes
Wasteful API calls Yes No No
Mid-job progress (%) No No Yes
Reconnection recovery Custom logic needed Retry delivery Last-Event-ID built-in
Best for CLI scripts, server-to-server Reliable server callbacks Browser UI, progress bars

SSE's key advantage: you get real-time, mid-job progress without needing a public HTTP endpoint, using only the browser's built-in EventSource API.

What Is Server-Sent Events

Server-Sent Events is a browser standard that allows a server to push data over a persistent HTTP connection. The server responds with Content-Type: text/event-stream and streams newline-delimited events as the job progresses.

Key properties:

  • Standard HTTP — works through proxies and load balancers; no special protocol upgrade
  • Browser-nativeEventSource is built into every modern browser; no library needed
  • Auto-reconnectEventSource reconnects automatically on disconnect
  • Gap recoveryLast-Event-ID tells the server where you left off; missed events are replayed

The SSE Endpoint

Spec

GET /api/pdf/jobs/{jobId}/events
Accept: text/event-stream
X-API-Key: sk-xxxx
Last-Event-ID: <id>    # optional — resumes from the last received event ID

You can also authenticate with Authorization: Bearer sk-xxxx.

jobId comes from two sources depending on the job type:

Job type Where to get jobId
Batch generation batch_id field in the POST /api/pdf/batch response
Single generation (/generate-from-html, etc.) X-Job-Id response header, or set it yourself via X-Job-Id request header

Authentication uses the same PDF API key you already have (generate / download scopes). No extra credentials needed.

The X-Job-Id Header

For single-generation jobs, X-Job-Id is how you link a generation request to an SSE stream.

  • Set it upfront: generate a UUID yourself, attach it as X-Job-Id on the generation request, and open the SSE stream before the job starts
  • Read it back: if you omit X-Job-Id, the server assigns one and returns it in the response header — subscribe to SSE afterwards using that value

Event Format and Phases

The stream delivers progress events in this format:

retry: 3000

id: 1748649600123-1
event: progress
data: {"phase":"queued","progress":0.0,"message":"enqueued","data":{},"occurred_at":"2026-05-31T01:00:00+00:00"}

id: 1748649600456-2
event: progress
data: {"phase":"rendering","progress":0.5,"message":"rendering pdf","data":{},"occurred_at":"2026-05-31T01:00:01+00:00"}

id: 1748649600789-3
event: progress
data: {"phase":"done","progress":1.0,"message":"batch completed","data":{"batch_id":"...","total_items":3,"completed_items":3,"failed_items":0},"occurred_at":"2026-05-31T01:00:02+00:00"}

Phase reference:

phase Meaning
queued Job accepted and queued
preprocessing HTML validation and font resolution
rendering PDF rendering in progress
postprocess Watermark, page numbers, metadata
uploading Uploading to S3/Wasabi
done Complete — server closes the stream
failed Failed — server closes the stream

The server closes the stream after sending done or failed. You should also call es.close() on the client side at that point.

Implementation Examples

curl (quickest way to test)

# Batch job
curl -N \
  -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  https://pdf.funbrew.cloud/api/pdf/jobs/<batch_uuid>/events

-N (--no-buffer) is required. Without it, curl buffers the response and you won't see events in real time.

For single-generation jobs — open SSE first, then submit:

# 1. Pre-generate a job ID
JOB_ID=$(uuidgen)

# 2. Open the SSE stream in the background
curl -N -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  "https://pdf.funbrew.cloud/api/pdf/jobs/$JOB_ID/events" &

# 3. Submit the generation request with the same job ID
curl -X POST https://pdf.funbrew.cloud/api/pdf/generate-from-html \
  -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  -H "X-Job-Id: $JOB_ID" \
  -H "Content-Type: application/json" \
  -d '{"html":"<h1>Hello FUNBREW PDF</h1>"}'

Or read the job ID from the response header after submitting:

RESP=$(curl -sD - -X POST https://pdf.funbrew.cloud/api/pdf/generate-from-html \
  -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html":"<h1>Hello FUNBREW PDF</h1>"}')

JOB_ID=$(echo "$RESP" | grep -i 'X-Job-Id' | cut -d' ' -f2 | tr -d '\r')

# Then subscribe
curl -N -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  "https://pdf.funbrew.cloud/api/pdf/jobs/$JOB_ID/events"

Browser EventSource API

If you are proxying the FUNBREW PDF API through your own backend (which you should do to avoid exposing API keys in the browser), the EventSource call is straightforward:

const jobId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';

const es = new EventSource(`/api/pdf/jobs/${jobId}/events`, {
  withCredentials: false,
});

es.addEventListener('progress', (event) => {
  const payload = JSON.parse(event.data);

  // Update progress bar (payload.progress is 0.0 – 1.0)
  updateProgressBar(payload.progress);
  showStatus(payload.phase, payload.message);

  // Close on terminal events
  if (payload.phase === 'done' || payload.phase === 'failed') {
    es.close();
    if (payload.phase === 'done') {
      console.log('Done:', payload.data);
    } else {
      console.error('Failed:', payload.message);
    }
  }
});

es.onerror = (err) => {
  // EventSource reconnects automatically.
  // Last-Event-ID is sent with each reconnect so no events are missed.
  console.warn('SSE connection error (reconnecting):', err);
};

Node.js (eventsource package)

Node.js doesn't include EventSource natively before v22. Install the eventsource package, or use Node.js 22+ where it is available globally.

import EventSource from 'eventsource';

const baseUrl = 'https://pdf.funbrew.cloud';
const apiKey = process.env.FUNBREW_PDF_API_KEY;
const batchUuid = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';

const es = new EventSource(`${baseUrl}/api/pdf/jobs/${batchUuid}/events`, {
  headers: { 'X-API-Key': apiKey },
});

es.addEventListener('progress', (e) => {
  const payload = JSON.parse(e.data);
  console.log(`[${payload.phase}] ${Math.round(payload.progress * 100)}% — ${payload.message}`);

  if (payload.phase === 'done' || payload.phase === 'failed') {
    es.close();
    process.exit(payload.phase === 'done' ? 0 : 1);
  }
});

@funbrew/pdf-client SDK (recommended)

The JS SDK (@funbrew/pdf-client) wraps EventSource and handles reconnection, Last-Event-ID, and event parsing for you.

import { PdfClient } from 'https://pdf.funbrew.cloud/sdk/pdf-client.esm.js';

const client = new PdfClient({
  baseUrl: 'https://pdf.funbrew.cloud',
  apiKey: 'sk-...',
});

// 1. Generate a job ID upfront
const jobId = crypto.randomUUID();

// 2. Subscribe to SSE
const sub = client.subscribeToJob(jobId, {
  onProgress: (event) => {
    console.log(`[${event.phase}] ${Math.round(event.progress * 100)}%`);
    updateProgressBar(event.progress);
  },
  onDone: (event) => {
    console.log('PDF ready:', event.data?.download_url);
    sub.close();
  },
  onError: (err) => {
    console.error('SSE error:', err);
  },
});

// 3. Submit the job using the same jobId
await client.generateFromHtml(
  { html: '<h1>Hello FUNBREW PDF</h1>', options: { page_size: 'A4' } },
  { jobId }
);

The same { jobId } second argument works for merge, compress, extractText, and toImage. See the JS SDK guide for the full API reference.

When to Use Which Approach

Scenario Recommended
Live progress bar in the browser SSE
CLI script or cron job Polling
Reliable server-to-server completion callback Webhook
No public inbound endpoint available SSE or polling
Long batch job (minutes) SSE + Webhook (best of both)
Short job (under 5 seconds) Synchronous API
High concurrency (100+ simultaneous jobs) Webhook (watch SSE connection limits)

SSE and Webhook complement each other well. SSE improves the user experience while the user is watching; Webhook reliably delivers the final result to your backend even if the browser closes. For production batch workflows, using both is the recommended pattern.

Operational Considerations

PHP-FPM Worker Occupancy

Each open SSE connection holds a PHP-FPM worker for its duration. If you expect many concurrent connections:

  • Create a dedicated FPM pool for SSE routes with a higher pm.max_children
  • Or run the SSE endpoint under FrankenPHP, Octane, or Swoole (thousands of connections per process)

Nginx Configuration

location /api/pdf/jobs/ {
    proxy_pass http://app;
    proxy_buffering off;          # required — buffering breaks real-time delivery
    proxy_cache off;
    proxy_read_timeout 3600s;     # accommodate long-running jobs
    proxy_http_version 1.1;
    proxy_set_header Connection '';
}

The API response includes X-Accel-Buffering: no, so Nginx's default behavior is usually fine — but setting proxy_buffering off explicitly removes any doubt.

Timeout and Max Duration

The server will close the stream after PDF_PROGRESS_MAX_DURATION seconds (default: 600). EventSource reconnects automatically and sends Last-Event-ID, so progress events resume without gaps.

Environment variable Default Description
PDF_PROGRESS_MAX_DURATION 600 Max seconds to hold a single SSE connection
PDF_PROGRESS_POLL_MS 500 Server-side polling interval for new events (ms)
PDF_PROGRESS_TTL 86400 Redis key TTL for stored events (seconds)

Stale Last-Event-ID (410 Gone)

If the Last-Event-ID you send is too old and is no longer in the Redis stream, the server responds with 410 Gone. In that case, fall back to a status poll to check the current job state before reopening SSE.

Resumable Large File Uploads

For workflows that combine file uploads with SSE progress tracking, the resumable upload guide (tus) covers how to upload large PDFs without losing progress on network interruption — a natural complement to SSE-based job monitoring.

Summary

FUNBREW PDF API's SSE streaming fills the gap between polling and webhooks: real-time, mid-job progress updates in the browser with no public endpoint required.

  • EventSource (browser-native) — no extra library needed in the browser
  • Works for batch and single-generation jobs — use X-Job-Id to link a single job to an SSE stream
  • Last-Event-ID prevents gaps — automatic reconnection picks up where it left off
  • SDK subscribeToJob — minimal boilerplate, handles reconnection and parsing for you

For production batch workflows, pair SSE (for live UI feedback) with Webhooks (for reliable backend callbacks). That combination gives you the best of both approaches.

Related Guides

Powered by FUNBREW PDF