Choosing where and how to render generated content such as certificates, social cards, invoices, and personalized marketing is an infrastructure decision with long-term consequences. The wrong choice shows up as leaked data paths, brittle reliability at scale, and cost blowups once volume grows.
This guide gives you a practical decision framework for choosing between client-side rendering, self-hosted rendering, and cloud/SaaS rendering. It focuses on privacy and compliance, throughput and latency, determinism, asset access, and operational ownership.
If you're evaluating Polotno SDK specifically, start with the Polotno SDK rendering overview.
If your goal is exports inside an embedded design editor, the export formats and rendering capabilities page is also relevant.
1. The three rendering modes
Client-side (browser-based)
Client-side rendering means the user's browser does the composition work and exports the final PNG/JPEG/PDF. This is the most common approach when you embed a design editor in your SaaS: the editor runs in the browser, users see instant previews, and many teams start by letting the browser handle exports.
In practical terms, the frontend loads a template (usually JSON), resolves asset URLs, draws to an HTML5 Canvas (or a DOM/SVG pipeline), and then exports using canvas.toBlob() or a PDF library. The upside is privacy and low infrastructure overhead. The downside is you inherit device variability (CPU/GPU/memory), browser quirks, and non-deterministic output across environments.
Here is a minimal client-side export flow. The logic is the same as before, but the payload is closer to what real SaaS teams ship: a template + a record with fields you would actually bind into a design (invoice number, due date, line items).
If you want to do this with Polotno directly, the same concept maps to exporting a design schema using store.toJSON() and later loading it with store.loadJSON(). See the import and export guide.
shared/project.json
{
"template": {
"title": "Invoice",
"currencySymbol": "$",
"brand": {
"name": "Northwind SaaS",
"primaryColor": "#4f46e5"
}
},
"data": {
"invoice_number": "INV-1042",
"customer_name": "Jordan Lee",
"due_date": "2026-05-01",
"line_items": [
{ "label": "Pro plan (May 2026)", "amount": 99 },
{ "label": "Extra seats (3)", "amount": 36 }
],
"subtotal": 135
}
}client/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Client-side rendering demo</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f5f7fb;
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 300px;
background: #ffffff;
border-right: 1px solid #ddd;
padding: 20px;
box-sizing: border-box;
}
.sidebar h2 {
margin-top: 0;
}
.input-group {
margin-bottom: 15px;
}
.input-group label {
display: block;
font-size: 14px;
margin-bottom: 5px;
}
.input-group input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 6px;
}
button {
width: 100%;
padding: 10px;
margin-top: 10px;
border: none;
border-radius: 6px;
background: #4f46e5;
color: white;
font-weight: bold;
cursor: pointer;
}
button:hover {
background: #4338ca;
}
.download-btn {
background: #10b981;
}
.download-btn:hover {
background: #059669;
}
/* Canvas area */
.main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
background: white;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<h2>Polotno Demo</h2>
<div class="input-group">
<label>Name</label>
<input id="name" placeholder="Enter name" />
</div>
<div class="input-group">
<label>Amount</label>
<input id="amount" placeholder="Enter amount" />
</div>
<button onclick="render()">Render design</button>
<button class="download-btn" onclick="downloadImage()">Download png</button>
</div>
<!-- Canvas -->
<div class="main">
<canvas id="canvas" width="500" height="300"></canvas>
</div>
<script src="app.js"></script>
</body>
</html>client/app.js
async function render() {
const res = await fetch("/shared/project.json");
const project = await res.json();
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// Clear
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Styling
ctx.fillStyle = "#111";
ctx.font = "20px Arial";
const name = document.getElementById("name").value || project.data.customer_name;
ctx.fillText(project.template.title, 50, 50);
ctx.fillText("Invoice: " + project.data.invoice_number, 50, 100);
ctx.fillText("Customer: " + name, 50, 130);
ctx.fillText("Due: " + project.data.due_date, 50, 160);
// "Good enough" example: totals are computed from a small line-item list.
const subtotal = (project.data.line_items || []).reduce(
(sum, item) => sum + (item.amount || 0),
0
);
ctx.fillText("Subtotal: " + project.template.currencySymbol + subtotal, 50, 210);
}
async function downloadImage() {
// Ensure the canvas is rendered before exporting.
await render();
const canvas = document.getElementById("canvas");
const project = await fetch("/shared/project.json").then((r) => r.json());
const fileName = `${project.data.invoice_number}.png`;
canvas.toBlob((blob) => {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
}, "image/png");
}Run it
Run a local server from your project root:
python -m http.server 3000Open http://localhost:3000 and click Render.
This approach works when users are present in the browser, volume is low (roughly under 500 renders/day), and data sensitivity or product constraints mean nothing should leave the client.
It becomes a poor fit when you need batch processing, deterministic output across devices, or "heavy" templates that exceed what mid-range hardware can render reliably in a few seconds. A common path is to keep client-side rendering for interactive preview, then add a server renderer for production exports.
Self-hosted (your infrastructure)
Self-hosted rendering moves exports off the user's device and into your own infrastructure. The canonical shape is:
- An API accepts a render request.
- The request is placed onto a queue.
- Workers pull jobs, render in a pinned environment, and write outputs to object storage.
- Your application returns a URL or pushes a callback/webhook.
This is the first option to consider when you need deterministic output, consistent performance, data residency controls, or you simply need to render more than browsers can reliably handle.
For Polotno-based systems, self-hosting usually means: accept a job containing a Polotno JSON design (from store.toJSON()), validate inputs, then render in a pinned environment.
If you are specifically interested in Polotno exports, these docs are the fastest way to ground the decision:
The example below keeps the same queue + worker loop logic, but it writes a structured "render artifact" (JSON metadata) instead of a plain text file.
server/app.py
from flask import Flask, request, jsonify
import queue
import threading
import json
from pathlib import Path
app = Flask(__name__)
job_queue: "queue.Queue[dict]" = queue.Queue()
OUTPUT_DIR = Path("./outputs")
OUTPUT_DIR.mkdir(exist_ok=True)
@app.post("/render")
def render():
job = request.get_json(force=True)
job_queue.put(job)
return jsonify({"status": "queued"})
def worker_loop():
while True:
job = job_queue.get()
try:
# In production, this is where you'd render via headless Chromium or
# a pinned rendering container.
customer_name = job["data"]["customer_name"]
amount = job["data"]["amount"]
# In real systems, this is where you'd upload a PNG/PDF to S3/GCS
# and return a URL. Here we write a JSON artifact to show the pattern.
out = OUTPUT_DIR / f"job-{customer_name.replace(' ', '_')}.json"
out.write_text(
json.dumps(
{
"status": "complete",
"customer_name": customer_name,
"amount": amount,
"output": {
"format": "png",
"url": "https://cdn.example.com/renders/" + customer_name.replace(" ", "_") + ".png",
},
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
print("Completed job ->", out)
finally:
job_queue.task_done()
threading.Thread(target=worker_loop, daemon=True).start()
if __name__ == "__main__":
app.run(port=5000)Run server
cd server
pip install flask
python app.pyTrigger render
Open another terminal, and paste the following curl command.
curl -X POST http://localhost:5000/render \
-H "Content-Type: application/json" \
-d @../shared/project.jsonTerminal (server logs)
Processing job: John DoeOutput appears in the outputs/ directory.
This is appropriate when you need deterministic output, handle PII, or render more than 500 items per day.
The trade-off is operational ownership. A self-hosted renderer is a service you maintain. Expect ongoing work on queue monitoring, autoscaling policy, browser version pinning, and asset caching. If rendering is core to your product, that ownership is often worth it. If it is incidental, cloud can be a better default.
Cloud/SaaS (managed service)
Cloud rendering pushes exports to a third-party API. You send the template + data payload over HTTPS, and the service returns a rendered output (or an async job you poll). The vendor manages browser pools, scaling, caching, and uptime. You manage integration quality, security review, and cost.
For Polotno specifically, the Cloud Render API accepts Polotno JSON and returns rendered images, PDFs, GIFs, and MP4s.
Cloud is usually the fastest way to ship server-side exports when you do not want to build and run a rendering fleet. The trade-offs are control, compliance surface area, and cost predictability at higher volumes.
Below is a small "shape of the integration" example. It demonstrates the request/response contract you typically build around a cloud renderer.
This localhost Flask server is a simulator for the demo. In production, your cloud endpoint is a vendor API, and you should treat it as an external dependency with retries, rate limit handling, and circuit breakers.
Step 1: Add a cloud render action
Add this inside your floating panel:
<button onClick={cloudRender}>Cloud render</button>Step 2: Create cloud render function
Add this in your component:
const cloudRender = async () => {
// For Polotno SDK, this typically comes from store.toJSON().
const design = store.toJSON();
const res = await fetch("http://localhost:5000/cloud-render", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(design),
});
if (!res.ok) {
throw new Error(`Cloud render failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
console.log("Cloud render result:", data);
alert("Rendered in cloud");
};Step 3: Upgrade Flask to simulate SaaS
Add server/app.py:
from flask import Flask, request, jsonify
from flask_cors import CORS
import os
import json
import time
app = Flask(__name__)
CORS(app)
@app.route("/cloud-render", methods=["POST"])
def cloud_render():
job = request.json
print("Cloud received job")
# simulate processing delay
time.sleep(2)
# extract dynamic content
try:
text_elements = [
el for el in job["pages"][0]["children"] if el["type"] == "text"
]
content = "\n".join([el["text"] for el in text_elements])
except:
content = "Invalid JSON"
# save output
with open("cloud-output.txt", "w") as f:
f.write(content)
return jsonify({
"status": "completed",
"message": "Rendered in cloud",
})
app.run(port=5000)Step 4: Run full demo
cd server
pip install flask-cors
python app.pyWhat you will see
1. Terminal (server)
Cloud received job2. File created
server/cloud-output.txtExample content:
Card title: Spring sale (retargeting)
Audience: cart abandoners (last 7 days)
Offer: 20% off with code SPRING20
CTA: Shop now -> example.comIf you're building a templates library and generating previews at scale, the templates library guide is a good companion to this decision.
3. Browser alert
Rendered in cloudWhat "rendering" actually includes
Rendering is not a single function call. It is a pipeline with discrete inputs, constraints, and outputs.
Inputs: A versioned template, a data payload for variable fields, and resolved references to fonts, images, logos, and backgrounds.
Outputs: Rasterized images (PNG, JPEG) or vector documents (PDF). Each format carries its own complexity. PDF rendering demands precise font embedding and color-space handling that PNG does not.
Constraints that break things: Fonts must be loaded and measured before layout. Images hosted on external domains trigger CORS restrictions that silently taint canvases. Deterministic output (identical input always producing byte-identical output) requires pinning browser versions, font hinting, and render timing.
The end-to-end rendering pipeline, regardless of where it runs, follows this flow:
def render_pipeline(template_id: str, version: str, data: dict, assets: dict, config: RenderConfig):
"""
The five-stage rendering pipeline:
1. Resolve — load template at exact version
2. Hydrate — inject data bindings into template
3. Fetch — resolve and download all assets (fonts, images)
4. Render — rasterize the composed layout
5. Deliver — write output and notify caller
"""
# Stage 1: Resolve
template = load_template(template_id, version) # pinned version, never "latest"
if template is None:
raise TemplateNotFoundError(f"{template_id}@{version}")
# Stage 2: Hydrate
hydrated = inject_data(template, data)
validate_required_fields(hydrated) # fail fast on missing data
# Stage 3: Fetch assets — generate fresh signed URLs at render time
resolved_assets = {}
for key, asset_ref in assets.items():
resolved_assets[key] = resolve_asset(
asset_ref,
ttl_seconds=300, # 5 min — enough for render, not a security risk
)
verify_asset_accessibility(resolved_assets) # HEAD request each URL
# Stage 4: Render
if config.format == "pdf":
output = render_to_pdf(hydrated, resolved_assets, config.dpi)
else:
output = render_to_image(hydrated, resolved_assets, config.format, config.dpi)
# Stage 5: Deliver
output_path = store_output(output, config)
return RenderResult(path=output_path, format=config.format, size_bytes=len(output))This pipeline is the same whether it runs in a browser, on your servers, or inside a vendor's infrastructure. The difference is who controls each stage, which failure modes you inherit, and where your data and assets must be accessible.
Decision flow
Start here. Work through these constraints in order, each one eliminates options.
Step 1 — Data sensitivity: If your render payloads contain PII, PHI, financial records, or anything governed by GDPR/HIPAA/SOC 2, eliminate cloud rendering unless the vendor's compliance certifications explicitly cover your requirements. Even then, audit the data flow. If compliance is non-negotiable and you cannot verify the vendor's posture, default to self-hosted.
Step 2 — Volume: If you render fewer than 500 outputs per day and users are present in the browser, client-side is viable. Between 500 and 10,000 per day, you need server-side rendering either self-hosted or cloud. Above 10,000 per day, client-side is ruled out entirely; choose between self-hosted and cloud based on cost and control.
Step 3 — Latency requirements: If users need sub-second previews while editing, client-side rendering is the only option for that interaction. Batch jobs that complete in minutes can run anywhere. This often leads to a hybrid: client-side for preview, server-side for production output.
Step 4 — Operational capacity: If your team cannot maintain a rendering worker fleet (queue monitoring, autoscaling, browser version pinning, asset caching), cloud removes that burden. If your team has strong DevOps capability and wants full control, self-hosted gives you that.
Summary table
| Criterion | Client-Side | Self-Hosted | Cloud/SaaS |
|---|---|---|---|
| Data stays in browser | Yes | No (your servers) | No (vendor servers) |
| Handles >10k/day | No | Yes | Yes |
| Sub-second preview | Yes | With caching | With caching |
| No ops overhead | Yes | No | Yes |
| Full pipeline control | Limited | Yes | Limited |
| Deterministic output | Hard | Achievable | Vendor-dependent |
Core decision criteria
Data sensitivity / compliance: If regulated data enters the render pipeline, self-hosted gives you the audit trail. Cloud requires contractual guarantees (DPA, BAA). Client-side keeps data local but offers no server-side audit log.
Volume (peak vs. average): Design for peak. If your average is 2,000/day but campaign launches spike to 50,000, client-side cannot absorb that. Self-hosted needs autoscaling. Cloud handles burst natively but charges for it.
Latency expectations: Interactive editing demands < 200ms render times, client-side only. Batch PDF generation tolerating 5–30 seconds per item opens all three options.
Cost model: Client-side costs are hidden in user device performance and support tickets. Self-hosted costs are predictable but include engineer time. Cloud costs scale linearly with volume.
Operational ownership: Every self-hosted renderer is a service your team must monitor, patch, and scale. If rendering is not your core product, this is a real cost.
Customization needs: If you need to control the exact Chromium version, inject custom fonts at the OS level, or apply watermarks in the rasterization step, self-hosted is the only option that gives you full control.
Privacy and compliance
Client-side: Template and data stay in the browser. Nothing leaves unless your code explicitly uploads it. No server logs of rendered content. This is the strongest privacy posture, but you have no server-side audit trail.
Self-hosted: Data flows to your servers. You control encryption at rest and in transit, log retention, and access policies. You can deploy in a specific region or VPC. Multi-tenant isolation is your responsibility. Run separate worker pools per tenant if required.
Cloud/SaaS: Data leaves your infrastructure. The vendor sees your templates, data payloads, and assets. Verify: Where are renders processed? Are payloads logged? For how long? Is the service multi-tenant, and if so, what isolation guarantees exist?
Throughput and scaling
Client-side limits: Rendering is single-threaded in the browser. Background tabs get throttled. Mobile devices overheat. A complex template with 15 layers and custom fonts takes 2–8 seconds per render on mid-range hardware. Concurrent renders compete for the same GPU.
If you're using Polotno and you see exports failing on some customer machines, the root cause is often fonts and assets. These docs are worth linking from internal runbooks:
Self-hosted scaling: The standard pattern is an API gateway fronting a message queue (SQS, RabbitMQ, Redis Streams) with a pool of workers pulling jobs. Autoscale workers based on queue depth. Expect each Puppeteer worker to handle 200–600 renders/hour depending on template complexity. Pre-warm browser instances to avoid cold-start penalties.
+--------------+
| API Gateway |
+------+-------+
|
v
+--------------+
| Job Queue |
| (SQS/Redis) |
+------+-------+
|
+---+---+
v v v
+----++----++----+
| W1 || W2 || W3 | <- Renderer Workers
+--+-++--+-++--+-+
| | |
v v v
+------------------+
| Object Storage |
| (S3 / GCS) |
+--------+---------+
|
v
+------------------+
| CDN |
+------------------+The autoscaling logic that matches this architecture:
# Autoscaler — runs on a cron (every 60 seconds)
import redis
import boto3
queue = redis.Redis(host="queue.internal", port=6379)
ecs = boto3.client("ecs")
RENDERS_PER_WORKER_PER_MINUTE = 6 # conservative: ~360/hour
MIN_WORKERS = 2
MAX_WORKERS = 50
SCALE_UP_THRESHOLD = 1.5 # queue depth > 1.5x current capacity
SCALE_DOWN_THRESHOLD = 0.3 # queue depth < 30% capacity
def autoscale():
queue_depth = queue.llen("render_jobs")
current_workers = get_running_worker_count()
current_capacity = current_workers * RENDERS_PER_WORKER_PER_MINUTE
if current_capacity == 0 or queue_depth > current_capacity * SCALE_UP_THRESHOLD:
desired = min(MAX_WORKERS, max(
MIN_WORKERS,
(queue_depth // RENDERS_PER_WORKER_PER_MINUTE) + 2 # +2 headroom
))
elif queue_depth < current_capacity * SCALE_DOWN_THRESHOLD:
desired = max(MIN_WORKERS, current_workers - 1) # scale down gently
else:
return # within normal range
ecs.update_service(
cluster="render-cluster",
service="render-workers",
desiredCount=desired,
)This autoscaler scales on queue depth, not CPU — CPU-based scaling reacts too slowly for bursty render workloads. If your queue grows from 0 to 10,000 in a minute (a campaign launch), CPU-based autoscaling will not notice until workers are already saturated.
Cloud scaling: The vendor handles this. Your concern is rate limits and burst pricing. Confirm: What is the maximum concurrent render count? What happens when you exceed it — queuing or rejection? What is the SLA during peak load?
Cost model
Client-side (hidden costs)
No infrastructure spend, but: increased support tickets from users with slow devices, inconsistent output quality across browsers, and engineering time debugging cross-platform rendering bugs. Estimate 10–20 engineering hours/month in maintenance for non-trivial templates.
Self-hosted (infra + ops)
Formula: Monthly cost = (workers × instance_cost) + (storage_gb × storage_rate) + (engineer_hours × hourly_rate)
Example: 5,000 renders/day, average 10 seconds each. You need roughly 2–3 workers running continuously.
- 3× c5.xlarge instances (or equivalent): ~$370/month
- S3 storage (50 GB): ~$1.15/month
- CDN egress (200 GB): ~$17/month
- Engineer time (8 hrs/month ops)
- Total: ~$400 + engineer time / month.
Cloud/SaaS (usage + egress)
Typical pricing: $0.01–$0.05 per render.
Example: 5,000 renders/day × 30 days × $0.02 = $3,000/month
Cloud is cheaper below ~1,000 renders/day. Self-hosted becomes more economical as volume increases and your team can absorb the operational load.
Reliability and failure modes
Client-side failures
CORS tainting is the most common silent failure. A single cross-origin image without proper headers corrupts the canvas. toBlob() throws a SecurityError with no useful message.
// Defensive asset loading — detect CORS issues before they taint the canvas
async function loadImageSafe(url) {
const img = new Image();
img.crossOrigin = "anonymous";
return new Promise((resolve, reject) => {
img.onload = () => {
// Verify the image is usable by testing a 1x1 canvas extraction
const test = document.createElement("canvas");
test.width = 1;
test.height = 1;
const ctx = test.getContext("2d");
ctx.drawImage(img, 0, 0);
try {
ctx.getImageData(0, 0, 1, 1); // throws if tainted
resolve(img);
} catch {
reject(new Error(`CORS: image loaded but tainted canvas — ${url}`));
}
};
img.onerror = () => reject(new Error(`Network: failed to load — ${url}`));
img.src = url;
});
}If you need deterministic output across environments, avoid this browser-based approach and move rendering to a controlled server environment where you pin the Chromium build and OS-level font configuration.
Font loading races: Text renders in a fallback font because the custom font had not finished loading. Always use document.fonts.ready and validate font availability before rendering.
Browser crashes: Large templates (50+ MB of assets) cause tab OOM kills on mobile Safari. No recovery path.
Self-hosted failures
Queue backlog: A spike in jobs without corresponding autoscaling causes renders to stack up. Users see delays or timeouts. Fix: autoscale on queue depth, not CPU.
Asset permission failures: Signed URLs expire between job creation and job execution. The fix is to resolve asset URLs at render time, inside the worker:
def resolve_assets(asset_refs: dict) -> dict:
"""Generate fresh signed URLs at render time, not at job submission."""
resolved = {}
for key, ref in asset_refs.items():
if ref.startswith("s3://"):
bucket, obj_key = parse_s3_uri(ref)
resolved[key] = s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": obj_key},
ExpiresIn=300, # 5 min — enough for this render
)
elif ref.startswith("https://"):
resolved[key] = ref # public URL, use directly
else:
raise AssetResolutionError(f"Unknown asset scheme: {ref}")
return resolvedThis pattern is critical whenever there is any delay between job creation and execution. If your queue can back up even 15 minutes during a spike, signed URLs with short TTLs will expire. Always resolve at render time.
Cold starts: Launching a new Puppeteer instance takes 2–5 seconds. Under burst load, cold starts dominate latency. Fix: maintain a warm pool of pre-launched browser instances in each worker.
Cloud failures
Vendor outages: You have zero control and limited visibility. Implement a circuit breaker with fallback:
// Circuit breaker — fall back to client-side for critical renders
class RenderCircuitBreaker {
constructor(failureThreshold = 5, resetTimeMs = 60000) {
this.failures = 0;
this.threshold = failureThreshold;
this.resetTime = resetTimeMs;
this.state = "closed"; // closed = normal, open = failing
this.lastFailure = 0;
}
async render(templateId, data, assets) {
if (this.state === "open") {
if (Date.now() - this.lastFailure > this.resetTime) {
this.state = "half-open"; // allow one test request
} else {
return this.fallbackToClientSide(templateId, data, assets);
}
}
try {
const result = await renderViaCloud(templateId, data, assets);
this.failures = 0;
this.state = "closed";
return result;
} catch (e) {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = "open";
}
return this.fallbackToClientSide(templateId, data, assets);
}
}
async fallbackToClientSide(templateId, data, assets) {
console.warn("Cloud render unavailable — falling back to client-side");
return renderInBrowser(templateId, data);
}
}This pattern is essential for any cloud-dependent rendering path. Without it, a vendor outage becomes your outage. The fallback to client-side will produce lower-fidelity output, but it keeps the system functional — use it for non-critical renders and queue critical renders for retry when the vendor recovers.
Throttling: Exceeding rate limits returns 429s. Use exponential backoff (shown in the cloud rendering code in Section 1).
Asset access patterns
Signed URLs: Generate short-lived URLs (5–15 min TTL) for private assets. Too short and they expire mid-render; too long and they become a security risk. Always resolve at render time, not at job submission.
Private assets in the browser: If assets live behind authentication, the renderer must have credentials. Client-side rendering with private assets requires a proxy endpoint:
# Asset proxy — authenticates against your storage and streams
# with correct CORS headers for browser rendering
@app.get("/assets/proxy/{asset_id}")
async def proxy_asset(asset_id: str, request: Request):
# Verify the requesting user has access
user = authenticate(request)
asset = get_asset_metadata(asset_id)
if not user_can_access(user, asset):
raise HTTPException(403)
# Fetch from private storage
body = s3.get_object(Bucket=asset.bucket, Key=asset.key)["Body"]
return StreamingResponse(
body,
media_type=asset.content_type,
headers={
"Access-Control-Allow-Origin": request.headers.get("Origin", "*"),
"Access-Control-Allow-Credentials": "true",
"Cache-Control": "private, max-age=300",
},
)If you find yourself building increasingly complex proxy layers to serve private assets to the browser, that is a signal to move rendering server-side where the worker has direct access to your storage layer without CORS concerns.
Determinism via versioning: Never reference logo.png — reference logo-v3-abc123.png. Mutable asset URLs produce non-deterministic renders. Pin template versions and asset versions together.
Common architectures
Browser preview + server batch (hybrid)
The most common hybrid. Users see instant previews rendered client-side while editing. When they click "generate," the job is dispatched to a server-side pipeline for high-resolution, deterministic output.
+--------------------------------------------------+
| Browser (Editing + Live Preview) |
| +----------+ +------------------------+ |
| | Template |--->| Client Renderer | |
| | Editor | | (Canvas — low-res,fast)| |
| +----------+ +------------------------+ |
+------------------------+-------------------------+
| "Generate" click
v
+-----------------+
| Render API |
+--------+--------+
v
+-----------------+
| Queue + Workers|
| (high-res, PDF) |
+--------+--------+
v
+-----------------+
| Storage / CDN |
+-----------------+The implementation that ties the browser and server together:
// Hybrid rendering controller — browser preview + server production
class HybridRenderer {
constructor(apiBase) {
this.apiBase = apiBase;
}
// Live preview — runs in browser, sub-200ms updates
async preview(template, data) {
return renderInBrowser(template, data); // low-res, 72 DPI, fast
}
// Production render — dispatches to server pipeline
async generate(templateId, templateVersion, data, assets, options = {}) {
const response = await fetch(`${this.apiBase}/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_id: templateId,
template_version: templateVersion,
data,
assets,
format: options.format || "png",
dpi: options.dpi || 300,
}),
});
const { job_id } = await response.json();
return this.pollForResult(job_id);
}
async pollForResult(jobId, maxWaitMs = 30000) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
const status = await fetch(`${this.apiBase}/render/${jobId}/status`)
.then(r => r.json());
if (status.status === "complete") return status.output_url;
if (status.status.startsWith("failed")) throw new Error(status.status);
await new Promise(r => setTimeout(r, 1000));
}
throw new Error("Render timed out");
}
}
// Usage
const renderer = new HybridRenderer("https://render.internal");
await renderer.preview(template, data); // instant feedback while editing
const pdfUrl = await renderer.generate( // server handles production output
"cert-v3", "3.2.1", data, assets, { format: "pdf", dpi: 300 }
);This is the architecture most teams converge on: client for speed, server for quality. The tradeoff is maintaining two rendering paths that must produce visually consistent (if not byte-identical) output.
Low-res vs high-res split
Render thumbnails (72 DPI, JPEG) client-side or via a fast lightweight renderer. Route high-resolution output (300 DPI, PNG/PDF) through the full server pipeline. This reduces server load by 60–80% for applications where most views are previews.
Staging vs production rendering
Maintain two rendering environments: staging uses the latest template versions and browser builds for QA; production uses pinned, tested versions. This prevents a Chromium update from silently changing production output.
Migration path
Phase 1 — MVP (client-side): Ship fast. Render in the browser. Accept the constraints: inconsistent output, limited scale, no batch processing. This is correct for validating the product.
Phase 2 — Growth (hybrid / self-hosted): When volume exceeds what browsers can handle or you need deterministic batch output, add a server-side rendering API. Keep client-side for previews. Deploy 2–5 workers behind a queue.
Phase 3 — Enterprise (controlled pipelines): Pin browser versions. Version all templates and assets. Add audit logging. Deploy per-tenant worker pools if multi-tenancy requires isolation. Build monitoring dashboards for queue depth, render latency, and failure rates.
Real-world scenarios
Scenario 1: startup MVP — event certificate generator
Constraints: 3-person team, < 200 certificates/event, users generate one at a time, no PII beyond names. Budget: near zero.
Recommendation: Client-side only. The user opens the certificate editor, enters their name, and exports a PNG directly. No server infrastructure needed. The team focuses entirely on product, not ops.
Justification: Volume is trivially low. Data is non-sensitive. The team has no capacity to manage infrastructure. Client-side is correct until the product proves itself.
Scenario 2: enterprise — insurance document generation with PII
Constraints: Policy documents contain names, addresses, policy numbers (PII). Must comply with SOC 2 and regional data residency. 2,000 documents/day. Output must be PDF with exact layout fidelity.
Recommendation: Self-hosted, deployed within the company's VPC. Workers pull from an internal queue. Assets are stored in a private bucket with no external access. Rendered PDFs are encrypted at rest.
Justification: PII rules out cloud unless the vendor's compliance posture is verified end-to-end — and most enterprise security teams will reject that dependency. Self-hosted gives full control over data flow, logging, and residency.
Scenario 3: high-volume VDP — personalized marketing (50,000 renders/day)
Constraints: 50,000 personalized social cards per day during campaign periods. Data includes names and purchase history. Latency tolerance: 30 seconds per render. Cost matters.
Recommendation: Self-hosted with autoscaling. 10–15 workers during campaigns, scaling down to 2 during off-peak. A cloud/SaaS vendor is viable if cost per render stays below $0.015 and compliance is acceptable.
Justification: At 50,000/day and $0.02/render, cloud costs $30,000/month. Self-hosted with autoscaling Kubernetes workers costs roughly $3,000–$5,000/month including ops time. The cost difference funds a dedicated engineer.
Quick guide: which should you choose?
- If you render < 500/day and users are present → client-side
- If you need deterministic, pixel-perfect output → self-hosted with pinned browser versions
- If you handle PII and cannot send data externally → self-hosted in your VPC
- If you need fast time-to-market with no ops team → cloud/SaaS
- If you need interactive previews + batch generation → hybrid (client + server)
- If you render > 10,000/day and want to control costs → self-hosted with autoscaling
- If you have unpredictable burst traffic and no autoscaling expertise → cloud/SaaS
Practical demonstration: end-to-end render
Input: A JSON payload combining a template reference with personalization data.
{
"template_id": "cert-v3.2.1",
"format": "png",
"dpi": 300,
"data": {
"recipient_name": "Priya Sharma",
"course_title": "Advanced Systems Design",
"completion_date": "2026-03-15",
"certificate_id": "ASD-29847"
},
"assets": {
"logo": "s3://brand-assets/logo-v2-a1b2c3.png",
"signature": "s3://brand-assets/sig-v1-d4e5f6.png"
}
}Rendering flow (implemented):
# Complete execution path for the job above
def handle_render_job(job: dict):
job_id = job["job_id"]
# 1. Load the pinned template version
template = template_store.get(job["template_id"]) # "cert-v3.2.1"
assert template is not None, f"Template {job['template_id']} not found"
# 2. Resolve assets — signed URLs generated NOW, not at submission
assets = resolve_assets(job["assets"])
# assets["logo"] -> "https://brand-assets.s3.amazonaws.com/logo-v2-a1b2c3.png?X-Amz-Expires=300&..."
# 3. Build the HTML with data bindings applied
html = template.render(
data=job["data"],
assets=assets,
)
# 4. Render in headless browser
with browser_pool.acquire() as page:
page.set_content(html, wait_until="networkidle")
page.wait_for_function("document.fonts.ready")
if job["format"] == "pdf":
output = page.pdf(format="A4", print_background=True)
else:
output = page.screenshot(
type=job["format"],
full_page=True,
scale=job["dpi"] / 96,
)
# 5. Store and deliver
output_key = f"renders/{job_id}/output.{job['format']}"
s3.put_object(Bucket="render-outputs", Key=output_key, Body=output)
# 6. Notify caller
if job.get("callback_url"):
requests.post(job["callback_url"], json={
"job_id": job_id,
"status": "complete",
"output_url": generate_cdn_url(output_key),
})
return output_keyOutput: A 300 DPI PNG certificate, byte-deterministic for the same input, available via CDN within seconds of completion.
FAQ
Should PDFs be rendered client-side or server-side?
Server-side. Client-side PDF generation struggles with font embedding, precise page layout, and CMYK color spaces. If your PDFs need to be print-ready or legally compliant, use a headless browser or a dedicated PDF engine on the server.
What is the best option for 10,000 renders per day?
Self-hosted or cloud. At 10,000/day, client-side is not viable — you need a queue-based pipeline. Choose self-hosted if you need cost control and have ops capacity. Choose cloud if you need to ship fast and can absorb the per-render cost.
How do I handle private assets in the browser?
Proxy them. Build a server endpoint that authenticates against your asset store, fetches the asset, and returns it with the appropriate Access-Control-Allow-Origin headers. Never expose raw signed URLs to the client if the asset is sensitive.
How do I ensure deterministic rendering?
Pin your browser version (exact Chromium build). Pin all fonts (do not rely on system fonts). Pin all asset versions (content-addressed URLs). Disable animations and time-dependent elements. Run renders in a consistent viewport size.
How do I estimate costs before launch?
Measure: render one template 100 times, record average duration. Multiply by your projected daily volume. For self-hosted, calculate instance-hours needed. For cloud, multiply volume by per-render price. Add 30% headroom for peak/burst traffic.
Glossary
Asset pipeline: The path assets (images, fonts, logos) take from storage to the renderer, including URL signing, caching, and access control.
Autoscaling: Automatically adjusting the number of renderer workers based on load signals such as queue depth.
Batch rendering: Rendering many outputs asynchronously (for example, generating thousands of invoices) rather than interactively in a user session.
CDN (content delivery network): A caching layer that serves rendered outputs (PNG, PDF) closer to end users, reducing latency and origin load.
CORS (cross-origin resource sharing): Browser security rules that control whether a canvas can use assets from another domain. Missing CORS headers can taint a canvas and break export.
Deterministic rendering: The same inputs (template version, data, assets, fonts, runtime) produce identical visual output every time.
DPI (dots per inch): Output resolution indicator. Higher DPI usually means larger files and more render time.
Egress: Data transferred out of a cloud provider or vendor network, often a cost driver for high-volume rendering.
Headless browser: Chromium (or similar) running without a UI, commonly used for server-side rendering.
Job queue: A buffer (SQS, Redis, RabbitMQ) that decouples accepting render requests from processing them in workers.
PII (personally identifiable information): Data that can identify an individual (name, address, policy number). This often changes what rendering modes are acceptable.
Pre-warming: Keeping browser instances or containers ready so workers avoid cold-start latency during spikes.
Render worker: A process/container that pulls a job from a queue, renders the output, and writes artifacts to storage.
Signed URL: A time-limited URL that grants temporary access to a private asset or output.
Template versioning: Pinning templates (and often assets) to immutable versions so renders do not change unexpectedly.
Throughput: How many renders a system can complete per unit time (for example, renders/hour), usually measured under a defined template complexity.
VDP (variable data printing): Generating large volumes of personalized outputs (for example, personalized mailers or ads) from a template + per-record data.
