reportgen
Back to all posts

Build Your Own PDF API, Part 3: Async Jobs, Storage and Webhooks With Plain Postgres

November 2, 2026

Part 3 of 6 of Build Your Own ReportGen.

The service from Parts 1 and 2 renders PDFs during the HTTP request. That breaks down when:

  • a 200-page report takes longer than your load balancer's timeout (often 30 or 60 seconds),
  • a burst of requests arrives at once and they all try to open Chrome pages simultaneously,
  • a render fails halfway and the caller has no way to retry except "do it all again".

The fix is the standard one: accept the job fast, do the work in the background, tell the caller when it's done.


Do You Need Temporal?

reportgen.io used Temporal for this. It gave us durable workflows, retries and full visibility into every job, and it was the right call for a public multi-tenant API. (Here's why we chose it.)

For your own service, start with a Postgres table. You almost certainly already run Postgres, so there's no new infrastructure, and a jobs table with FOR UPDATE SKIP LOCKED is a reliable queue. Graduate to Temporal, BullMQ or a cloud queue when you need multi-step workflows or have outgrown it, not before.


The Design

client ──POST /jobs──▶ API ──INSERT──▶ pdf_jobs table ◀──claim── worker(s)
                         │                                           │
client ◀─202 + job id────┘                     render → upload to S3 → webhook
client ──GET /jobs/:id──▶ API ──▶ status + signed download URL

Every job ends in exactly one of two terminal states: done or failed. Never stuck in between. Most of the code below exists to guarantee that.


The Jobs Table

CREATE TABLE IF NOT EXISTS pdf_jobs (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  status       text NOT NULL DEFAULT 'queued'
               CHECK (status IN ('queued', 'running', 'done', 'failed')),
  template     text NOT NULL,
  data         jsonb NOT NULL DEFAULT '{}',
  webhook_url  text,
  attempts     int  NOT NULL DEFAULT 0,
  max_attempts int  NOT NULL DEFAULT 3,
  run_after    timestamptz NOT NULL DEFAULT now(),
  locked_at    timestamptz,
  object_key   text,
  error        text,
  created_at   timestamptz NOT NULL DEFAULT now(),
  finished_at  timestamptz
);
 
-- The worker's claim query only ever looks at queued jobs that are due.
CREATE INDEX IF NOT EXISTS pdf_jobs_due ON pdf_jobs (run_after) WHERE status = 'queued';

Each column has a job:

  • attempts / max_attempts: retry budget.
  • run_after: when the job may next run. Retries push it into the future, which gives you backoff for free.
  • locked_at: when a worker claimed it. Used to detect workers that died mid-job.
  • object_key: where the finished PDF lives in the bucket.

Storage

Don't store PDFs in Postgres. Put them in S3-compatible object storage (AWS S3, Cloudflare R2, MinIO) and hand out short-lived signed URLs.

npm install pg @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// storage.js — works with AWS S3, Cloudflare R2, MinIO, or any S3-compatible store
const { S3Client, PutObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
 
const s3 = new S3Client({
  region: process.env.S3_REGION || "auto",
  endpoint: process.env.S3_ENDPOINT, // e.g. https://<account>.r2.cloudflarestorage.com
  forcePathStyle: true,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
  },
});
const Bucket = process.env.S3_BUCKET;
 
async function putPdf(key, buffer) {
  await s3.send(new PutObjectCommand({ Bucket, Key: key, Body: buffer, ContentType: "application/pdf" }));
}
 
// Hand out short-lived links instead of making the bucket public.
function signedUrl(key, expiresIn = 3600) {
  return getSignedUrl(s3, new GetObjectCommand({ Bucket, Key: key }), { expiresIn });
}
 
module.exports = { putPdf, signedUrl };

Cloudflare R2 has no egress fees, which matters if users download a lot of PDFs. That's what reportgen.io used.


The API

// api.js — accept jobs fast, let workers do the slow part
const express = require("express");
const { Pool } = require("pg");
const { signedUrl } = require("./storage");
 
const db = new Pool({ connectionString: process.env.DATABASE_URL });
// Without this, one dropped idle connection (DB restart, failover) crashes the process.
db.on("error", (err) => console.error("idle pg client error:", err.message));
const app = express();
app.use(express.json({ limit: "5mb" }));
 
app.post("/jobs", async (req, res) => {
  const { template, data = {}, webhookUrl = null } = req.body;
  if (typeof template !== "string" || !template) {
    return res.status(400).json({ error: "template is required" });
  }
  const { rows } = await db.query(
    `INSERT INTO pdf_jobs (template, data, webhook_url) VALUES ($1, $2, $3) RETURNING id, status`,
    [template, data, webhookUrl]
  );
  res.status(202).json(rows[0]); // 202 Accepted: "we have it, check back later"
});
 
app.get("/jobs/:id", async (req, res) => {
  const { rows } = await db.query(
    `SELECT id, status, attempts, error, object_key, created_at, finished_at FROM pdf_jobs WHERE id = $1`,
    [req.params.id]
  );
  if (!rows[0]) return res.status(404).json({ error: "not found" });
  const { object_key, ...job } = rows[0];
  if (job.status === "done") job.url = await signedUrl(object_key);
  res.json(job);
});
 
module.exports = { app, db };
if (require.main === module) app.listen(process.env.PORT || 3000);

The API never touches Chrome. It just writes a row and returns 202 Accepted, which takes milliseconds, so the API stays fast even when workers are backed up.


The Worker

This is the heart of it. It uses the renderHtml and renderPdf functions from Part 2.

// worker.js — claim a job, render it, store it, notify
const crypto = require("crypto");
const puppeteer = require("puppeteer");
const { Pool } = require("pg");
const { renderHtml, renderPdf } = require("./render");
const { putPdf } = require("./storage");
 
const db = new Pool({ connectionString: process.env.DATABASE_URL });
// Without this, one dropped idle connection (DB restart, failover) crashes the process.
db.on("error", (err) => console.error("idle pg client error:", err.message));
const STALE_AFTER = "5 minutes"; // a job "running" longer than this is presumed dead
 
// Atomically take one due job. SKIP LOCKED means two workers never get the same row,
// and neither waits on the other.
async function claimJob() {
  const { rows } = await db.query(`
    UPDATE pdf_jobs SET status = 'running', locked_at = now(), attempts = attempts + 1
    WHERE id = (
      SELECT id FROM pdf_jobs
      WHERE status = 'queued' AND run_after <= now()
      ORDER BY run_after
      FOR UPDATE SKIP LOCKED
      LIMIT 1
    )
    RETURNING *`);
  return rows[0];
}
 
// If a worker crashes mid-job, its row stays 'running' forever. Put it back.
async function reapStaleJobs() {
  await db.query(`
    UPDATE pdf_jobs SET status = 'queued', locked_at = NULL
    WHERE status = 'running' AND locked_at < now() - $1::interval`, [STALE_AFTER]);
}
 
class PermanentError extends Error {} // retrying won't help (bad template, bad data)
 
async function processJob(browser, job) {
  let html;
  try {
    html = renderHtml(job.template, job.data);
  } catch (err) {
    throw new PermanentError(`template error: ${err.message}`);
  }
  const pdf = await renderPdf(browser, html);
  const key = `pdfs/${job.id}.pdf`;
  await putPdf(key, pdf);
  return key;
}
 
async function finish(job, fields) {
  await db.query(
    `UPDATE pdf_jobs SET status = $2, object_key = $3, error = $4, finished_at = now(), locked_at = NULL
     WHERE id = $1`,
    [job.id, fields.status, fields.objectKey || null, fields.error || null]
  );
  if (job.webhook_url) await notify(job.webhook_url, { id: job.id, status: fields.status, error: fields.error || null });
}
 
async function handleFailure(job, err) {
  const retryable = !(err instanceof PermanentError) && job.attempts < job.max_attempts;
  if (!retryable) return finish(job, { status: "failed", error: err.message });
  const delaySec = 5 * 2 ** (job.attempts - 1); // 5s, 10s, 20s...
  await db.query(
    `UPDATE pdf_jobs SET status = 'queued', locked_at = NULL, error = $2,
       run_after = now() + make_interval(secs => $3) WHERE id = $1`,
    [job.id, err.message, delaySec]
  );
}
 
// Sign webhook bodies so receivers can verify they came from you.
async function notify(url, payload) {
  const body = JSON.stringify(payload);
  const sig = crypto.createHmac("sha256", process.env.WEBHOOK_SECRET || "dev").update(body).digest("hex");
  try {
    await fetch(url, {
      method: "POST",
      headers: { "content-type": "application/json", "x-signature": sig },
      body,
      signal: AbortSignal.timeout(10_000),
    });
  } catch (err) {
    console.error(`webhook to ${url} failed: ${err.message}`); // don't fail the job over it
  }
}
 
async function main({ concurrency = 2, pollMs = 1000 } = {}) {
  const browser = await puppeteer.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] });
  let stopping = false;
  process.on("SIGTERM", () => (stopping = true)); // finish current jobs, then exit
 
  setInterval(() => reapStaleJobs().catch(console.error), 60_000).unref();
 
  const loop = async () => {
    while (!stopping) {
      const job = await claimJob();
      if (!job) { await new Promise((r) => setTimeout(r, pollMs)); continue; }
      try {
        const objectKey = await processJob(browser, job);
        await finish(job, { status: "done", objectKey });
      } catch (err) {
        await handleFailure(job, err);
      }
    }
  };
  await Promise.all(Array.from({ length: concurrency }, loop));
  await browser.close();
  await db.end();
}
 
module.exports = { main, claimJob, reapStaleJobs };
if (require.main === module) main();

How the claim query works

SELECT id FROM pdf_jobs WHERE status = 'queued' AND run_after <= now()
ORDER BY run_after FOR UPDATE SKIP LOCKED LIMIT 1

FOR UPDATE locks the row so no one else can claim it. SKIP LOCKED tells other workers to skip rows that are already locked instead of waiting for them. So ten workers polling at the same moment each get a different job, and none of them block each other. Wrapping it in the UPDATE ... WHERE id = (...) makes the claim and the status change a single atomic statement.

We tested this with two worker processes racing over 22 jobs: every job ran exactly once.

Retries that know when to give up

Not all failures are equal:

  • Template errors (bad syntax, missing field in strict mode) will fail the same way every time. Retrying wastes resources and delays the error. We throw a PermanentError and fail immediately.
  • Everything else (Chrome crashed, S3 hiccup, timeout) often succeeds on a second try. We retry with exponential backoff (5s, 10s, 20s) by pushing run_after into the future.

When attempts reaches max_attempts, the job is marked failed with the last error. It ends either way.

Surviving crashed workers

If a worker is killed mid-render (deploy, out-of-memory, the machine dies), its job stays running forever. Nobody will ever pick it up, and the customer waits forever.

reapStaleJobs fixes that: any job running for longer than 5 minutes goes back to queued. Set the threshold comfortably above your slowest legitimate render. Because attempts was already incremented on claim, a job that keeps crashing workers still runs out of attempts and ends as failed instead of looping forever.

Graceful shutdown

On SIGTERM (what Docker, Kubernetes and most hosts send on deploy) the worker stops claiming new jobs, finishes the ones in progress, then exits. Combined with the reaper, deploys never lose jobs.

The Postgres error handler

Notice db.on("error", ...) near the top of both files. Our test suite caught this one: when Postgres drops an idle pooled connection (a restart, a failover, a network blip), node-postgres emits an error event on the pool. With no listener, Node treats it as an unhandled error and the whole process crashes. It's one line, and you want it in every service that uses pg.Pool.


Webhooks

Polling GET /jobs/:id works, but webhooks are nicer: the worker POSTs to the caller's URL when the job finishes.

Two rules:

  1. Sign the payload. The worker sends an x-signature header with an HMAC-SHA256 of the body. Receivers should verify it, or anyone could fake "your PDF is ready" calls:
const crypto = require("crypto");
 
function verifyWebhook(rawBody, signature, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
  1. A failed webhook must not fail the job. The PDF was generated successfully; the customer's endpoint being down is a separate problem. Log it (or queue webhook retries separately) and move on. They can still poll.

Also: webhook URLs are user input, and your worker is about to make an HTTP request to them. Apply the same private-address checks from Part 5 before calling them.


Running It

psql "$DATABASE_URL" -f schema.sql
DATABASE_URL=... S3_ENDPOINT=... S3_BUCKET=... S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... node api.js
DATABASE_URL=... S3_ENDPOINT=... S3_BUCKET=... S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... node worker.js
curl -X POST localhost:3000/jobs -H 'content-type: application/json' \
  -d '{"template":"<h1>Hi {{name}}</h1>","data":{"name":"Ana"}}'
# {"id":"6f1c...","status":"queued"}
 
curl localhost:3000/jobs/6f1c...
# {"id":"6f1c...","status":"done","attempts":1,"url":"https://...signed..."}

Need more throughput? Start more worker.js processes, on the same machine or others. They coordinate through Postgres automatically.


Housekeeping

Two things to schedule once you're in production:

  • Delete old PDFs. Set a lifecycle rule on the bucket (e.g. delete after 30 days) instead of writing cleanup code.
  • Prune old job rows. DELETE FROM pdf_jobs WHERE finished_at < now() - interval '30 days' in a nightly cron keeps the table small.

Frequently Asked Questions

Isn't polling Postgres every second wasteful? One indexed query per worker per second is nothing for Postgres. If you want instant pickup, add LISTEN/NOTIFY: the API sends NOTIFY pdf_jobs after inserting, and idle workers wake up immediately. Keep the polling as a fallback.

When should I switch to a real queue or Temporal? When jobs become multi-step workflows (render, then merge, then email, then bill), when you need per-step visibility, or when the job volume makes Postgres the bottleneck. A jobs table handles more than most people expect.

Can I keep a synchronous endpoint too? Yes. reportgen.io offered both. A sync endpoint can just insert the job and poll for it internally with a timeout, so both paths share the same workers and limits.


Next: Part 4: Running Chrome in Production. Our worker shares one browser across concurrent jobs, and Chrome has opinions about that.