Real-Time PDF Progress Streaming with SSE and X-Job-Id
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-native —
EventSourceis built into every modern browser; no library needed - Auto-reconnect —
EventSourcereconnects automatically on disconnect - Gap recovery —
Last-Event-IDtells 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-Idon 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-Idto link a single job to an SSE stream Last-Event-IDprevents 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
- Polling vs Webhooks for Async PDF Status Tracking — detailed polling and webhook implementation patterns
- JS SDK (@funbrew/pdf-client) Guide — full SDK reference including
subscribeToJob - Resumable File Upload with tus Guide — reliable large-file uploads to complement SSE job tracking
- API Documentation — endpoint reference, authentication, and rate limits
- Playground — try SSE in the browser interactively