Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.blinkpdf.io/llms.txt

Use this file to discover all available pages before exploring further.

When you need to produce hundreds or thousands of PDFs in a single run — month-end invoices, bulk certificate generation, mass document digitization — the key is concurrency. Blink PDF can render roughly 30 PDFs per second per node, and your throughput scales with the number of parallel requests you issue within your plan’s rate limit. This guide shows you how to structure a concurrent batch job in Python and Node.js, handle rate limit errors gracefully, and size your plan for the throughput you need.

Concurrency Limits by Plan

Your plan’s rate limit determines how many requests you can issue per minute. For batch workloads, translate that into a practical concurrency ceiling:
PlanRate LimitRecommended Max Concurrency500 PDFs in ~…
Free5/min1~100 min
Starter ($12/mo)30/min3~17 min
Pro ($39/mo)120/min10~4 min
Business ($149/mo)600/min40~50 sec
Scale ($999/mo)~5,000/minper capacity~6 sec
These concurrency figures are conservative estimates that keep you safely below the rate limit ceiling while accounting for variable render times. You can tune the concurrency value up or down based on observed throughput in your environment.

Python: Async Batch with asyncio and aiohttp

The most efficient Python approach uses asyncio with aiohttp to issue multiple render requests in parallel, capped by a semaphore set to your concurrency limit.
import asyncio
import aiohttp
import time
from pathlib import Path

BLINK_API_KEY = "sk_live_..."
BLINK_URL = "https://api.blinkpdf.io/v1/render"

# Set this to the recommended concurrency for your plan:
# Free=1, Starter=3, Pro=10, Business=40, Scale=adjust as needed
MAX_CONCURRENCY = 10  # Pro plan example


async def render_pdf(
    session: aiohttp.ClientSession,
    semaphore: asyncio.Semaphore,
    document: dict,
    retries: int = 4,
) -> tuple[str, bytes | None]:
    """
    Render a single PDF with exponential backoff on 429 / 5xx errors.
    Returns (document_id, pdf_bytes) or (document_id, None) on failure.
    """
    async with semaphore:
        delay = 1.0  # initial backoff in seconds
        for attempt in range(retries):
            try:
                async with session.post(
                    BLINK_URL,
                    headers={
                        "Authorization": f"Bearer {BLINK_API_KEY}",
                        "Content-Type": "application/json",
                    },
                    json={
                        "markdown": document["markdown"],
                        "metadata": {"title": document["title"]},
                    },
                ) as response:
                    if response.status == 200:
                        pdf_bytes = await response.read()
                        render_ms = response.headers.get("X-Render-Ms", "?")
                        print(
                            f"  ✓ {document['id']} rendered in {render_ms}ms"
                        )
                        return document["id"], pdf_bytes

                    elif response.status == 429:
                        # Rate limited — back off and retry
                        print(
                            f"  ⚠ {document['id']} rate limited, "
                            f"retrying in {delay:.1f}s (attempt {attempt + 1}/{retries})"
                        )
                        await asyncio.sleep(delay)
                        delay *= 2  # exponential backoff

                    else:
                        body = await response.text()
                        print(
                            f"  ✗ {document['id']} failed with "
                            f"{response.status}: {body[:120]}"
                        )
                        return document["id"], None

            except aiohttp.ClientError as exc:
                print(f"  ✗ {document['id']} network error: {exc}")
                await asyncio.sleep(delay)
                delay *= 2

        print(f"  ✗ {document['id']} exhausted retries")
        return document["id"], None


async def batch_render(documents: list[dict], output_dir: str = "output") -> dict:
    """
    Render all documents concurrently and save PDFs to output_dir.
    Returns a summary dict with success/failure counts.
    """
    Path(output_dir).mkdir(parents=True, exist_ok=True)
    semaphore = asyncio.Semaphore(MAX_CONCURRENCY)

    connector = aiohttp.TCPConnector(limit=MAX_CONCURRENCY)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [
            render_pdf(session, semaphore, doc) for doc in documents
        ]

        start = time.monotonic()
        results = await asyncio.gather(*tasks)
        elapsed = time.monotonic() - start

    success, failure = 0, 0
    for doc_id, pdf_bytes in results:
        if pdf_bytes is not None:
            out_path = Path(output_dir) / f"{doc_id}.pdf"
            out_path.write_bytes(pdf_bytes)
            success += 1
        else:
            failure += 1

    print(
        f"\nBatch complete: {success} succeeded, {failure} failed "
        f"in {elapsed:.1f}s ({len(documents) / elapsed:.1f} PDFs/sec)"
    )
    return {"success": success, "failure": failure, "elapsed_s": elapsed}


# Example: build a list of 100 documents
documents = [
    {
        "id": f"report_{i:04d}",
        "title": f"Monthly Report — Account {i:04d}",
        "markdown": f"# Monthly Report\n\n**Account:** {i:04d}\n\nContent for account {i}.",
    }
    for i in range(1, 101)
]

asyncio.run(batch_render(documents, output_dir="reports"))
Set MAX_CONCURRENCY to the value in the plan table above and tune it upward only after confirming you are not hitting 429 errors in practice. Starting conservative saves you from failed renders at the beginning of a large run.

Node.js: Concurrent Batch with Promise.all and a Concurrency Pool

Node.js’s Promise.all runs tasks concurrently, but without a concurrency cap it will fire all requests simultaneously and overwhelm the rate limit. The renderBatch function below implements a simple pool that keeps exactly MAX_CONCURRENCY requests in flight at any time.
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";

const BLINK_API_KEY = "sk_live_...";
const BLINK_URL = "https://api.blinkpdf.io/v1/render";
const MAX_CONCURRENCY = 10; // Adjust for your plan
const OUTPUT_DIR = "reports";

mkdirSync(OUTPUT_DIR, { recursive: true });

async function renderWithRetry(document, retries = 4) {
  let delay = 1000; // ms

  for (let attempt = 0; attempt < retries; attempt++) {
    const response = await fetch(BLINK_URL, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${BLINK_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        markdown: document.markdown,
        metadata: { title: document.title },
      }),
    });

    if (response.ok) {
      const buffer = Buffer.from(await response.arrayBuffer());
      console.log(
        `  ✓ ${document.id} rendered in ${response.headers.get("X-Render-Ms")}ms`
      );
      return { id: document.id, buffer };
    }

    if (response.status === 429) {
      console.log(
        `  ⚠ ${document.id} rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${retries})`
      );
      await new Promise((r) => setTimeout(r, delay));
      delay *= 2; // exponential backoff
      continue;
    }

    const text = await response.text();
    console.error(`  ✗ ${document.id} failed: ${response.status} ${text.slice(0, 120)}`);
    return { id: document.id, buffer: null };
  }

  console.error(`  ✗ ${document.id} exhausted retries`);
  return { id: document.id, buffer: null };
}

async function runPool(documents, concurrency) {
  const results = [];
  const queue = [...documents];
  const inFlight = new Set();

  await new Promise((resolve) => {
    function startNext() {
      while (inFlight.size < concurrency && queue.length > 0) {
        const doc = queue.shift();
        const promise = renderWithRetry(doc).then((result) => {
          inFlight.delete(promise);
          results.push(result);
          if (queue.length === 0 && inFlight.size === 0) resolve();
          else startNext();
        });
        inFlight.add(promise);
      }
    }
    startNext();
  });

  return results;
}

// Build example documents
const documents = Array.from({ length: 100 }, (_, i) => ({
  id: `report_${String(i + 1).padStart(4, "0")}`,
  title: `Monthly Report — Account ${String(i + 1).padStart(4, "0")}`,
  markdown: `# Monthly Report\n\n**Account:** ${String(i + 1).padStart(4, "0")}\n\nContent for account ${i + 1}.`,
}));

console.log(`Rendering ${documents.length} PDFs with concurrency=${MAX_CONCURRENCY}...`);
const start = Date.now();

const results = await runPool(documents, MAX_CONCURRENCY);

let success = 0;
let failure = 0;
for (const { id, buffer } of results) {
  if (buffer) {
    writeFileSync(join(OUTPUT_DIR, `${id}.pdf`), buffer);
    success++;
  } else {
    failure++;
  }
}

const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(
  `\nBatch complete: ${success} succeeded, ${failure} failed in ${elapsed}s ` +
    `(${(documents.length / elapsed).toFixed(1)} PDFs/sec)`
);

Handling Rate Limit Errors

When you exceed your plan’s rate limit, the API returns HTTP 429 Too Many Requests. Both examples above implement exponential backoff — they wait 1 second after the first 429, 2 seconds after the second, 4 seconds after the third, and so on. Key principles for robust batch jobs:
  • Never retry immediately on 429. Back off before each retry attempt.
  • Cap your retries (4–5 attempts is usually sufficient). After that, log the failure and move on so the rest of the batch can complete.
  • Log failures with document IDs so you can re-run just the failed subset without re-processing the whole batch.
  • Monitor X-Render-Ms across your batch run — sustained render times above 200ms may indicate downstream capacity pressure.
If you consistently hit 429 errors even at the recommended concurrency levels, your workload has outgrown your current plan. Upgrading to the next plan tier immediately increases your rate limit and allows higher concurrency.

Sizing Your Plan for Batch Workloads

Use this formula to estimate the minimum plan you need:
Required rate (PDFs/min) = batch_size / acceptable_duration_minutes
Example: You need to generate 10,000 invoices within 30 minutes.
Required rate = 10,000 / 30 ≈ 334 PDFs/min
The Business plan (600/min) covers this comfortably. The Pro plan (120/min) would require ~83 minutes.

Pro Plan — $39/mo

120 req/min · 25k PDFs/mo — Good for nightly batch jobs of a few hundred PDFs or on-demand runs of up to ~1,000 documents.

Business Plan — $149/mo

600 req/min · 200k PDFs/mo — Suitable for large month-end batch runs, bulk document digitization, and high-frequency automated pipelines.
The Scale plan ($999/mo) offers unlimited fair-use throughput and is designed for enterprise workloads that require sustained high-volume generation. Contact sales to discuss capacity planning for Scale-tier batch workloads.