Polotno

Self-hosted vs cloud vs client-side rendering: how to choose

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

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

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

javascript
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:

bash
python -m http.server 3000

Open 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:

  1. An API accepts a render request.
  2. The request is placed onto a queue.
  3. Workers pull jobs, render in a pinned environment, and write outputs to object storage.
  4. 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

python
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

bash
cd server
pip install flask
python app.py

Trigger render

Open another terminal, and paste the following curl command.

bash
curl -X POST http://localhost:5000/render \
  -H "Content-Type: application/json" \
  -d @../shared/project.json

Terminal (server logs)

bash
Processing job: John Doe

Output 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:

javascript
<button onClick={cloudRender}>Cloud render</button>

Step 2: Create cloud render function

Add this in your component:

javascript
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:

python
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

bash
cd server
pip install flask-cors
python app.py

What you will see

1. Terminal (server)

bash
Cloud received job

2. File created

bash
server/cloud-output.txt

Example content:

tsx
Card title: Spring sale (retargeting)
Audience: cart abandoners (last 7 days)
Offer: 20% off with code SPRING20
CTA: Shop now -> example.com

If 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

tsx
Rendered in cloud

What "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:

python
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

CriterionClient-SideSelf-HostedCloud/SaaS
Data stays in browserYesNo (your servers)No (vendor servers)
Handles >10k/dayNoYesYes
Sub-second previewYesWith cachingWith caching
No ops overheadYesNoYes
Full pipeline controlLimitedYesLimited
Deterministic outputHardAchievableVendor-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.

tsx
+--------------+
|  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:

python
# 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.

javascript
// 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:

python
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 resolved

This 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:

javascript
// 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:

python
# 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.

tsx
+--------------------------------------------------+
|  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:

javascript
// 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.

json
{
  "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):

python
# 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_key

Output: 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.

Skip the build, cut dev costs, launch faster

TRUSTED BY

100,000+

CREATORS

300+

BUSINESSES

ExpediaUnbounceLovePopPostGridPredis.ai