Node.js · Puppeteer · Automation
Turning a daily 5-minute task into one click with headless Puppeteer
Instead of rebuilding a report, run the app people already trust on the server. The pattern, the pitfalls and what matters in a bank.
At RBC Capital Markets I worked on RiskHub, an internal risk platform where people keep dozens of tables and charts open at once. One request kept coming back from the brokers: they configured a view in the platform, and then, every day, spent four or five minutes turning it into an email for the people who needed it.
Four or five minutes doesn't sound like much. Done every day by dozens of people, it's hours a week of skilled time spent copying numbers around, plus the risk of copying the wrong ones.
The obvious fix was a separate reporting service. I did something simpler: a headless Node.js service that runs the application itself on the server, with Puppeteer, replays the broker's configuration, and sends the result. For the broker, the whole task became one click.
I can't share anything specific about the bank's platform, so this note is the general pattern, with code written from scratch.
Why run the app instead of rebuilding the report
- One source of truth. The numbers in the email come from the same code the broker sees on screen: same calculations, same filters, same formatting. A second implementation will drift from the first, usually in the places that matter.
- Every new view is supported for free. If someone can configure it in the UI, the service can send it.
- It ships faster. Most of the work is plumbing around a browser, not re-implementing business logic.
The trade-off is cost and fragility. A headless browser is heavier than a database query, and a careless script breaks every time the UI changes. Both are manageable if you design for them.
Wait for the app, not for time
The most common reason these scripts are flaky is waitForTimeout(5000): wait a bit and hope the data is there. The fix is to let the application announce when it's ready. A tiny flag set by the app after its data and charts have rendered is far more reliable than any selector or timer.
// In the app: flip a flag once the view has data and the charts have rendered
useEffect(() => {
if (status === "ready") window.__REPORT_READY__ = true;
}, [status]);import puppeteer, { type Browser } from "puppeteer";
let browser: Browser | undefined;
// Reuse one browser and open a fresh, isolated context per report
async function getBrowser() {
browser ??= await puppeteer.launch({ headless: true });
return browser;
}
export async function renderReport(viewId: string, token: string) {
const context = await (await getBrowser()).createBrowserContext();
const page = await context.newPage();
try {
await page.setViewport({ width: 1440, height: 900 });
await page.setExtraHTTPHeaders({ Authorization: `Bearer ${token}` });
await page.goto(`${process.env.APP_URL}/views/${viewId}?mode=report`, { waitUntil: "networkidle0", timeout: 60_000 });
// The app tells us when it's done; no guessing
await page.waitForFunction(() => window.__REPORT_READY__ === true, { timeout: 60_000 });
// Pull structured data from the page for the email body, and a PDF for the attachment
const rows = await page.$$eval("[data-report-row]", (els) => els.map((el) => el.textContent?.trim() ?? ""));
const pdf = await page.pdf({ format: "A4", landscape: true, printBackground: true });
return { rows, pdf };
} finally {
await context.close();
}
}A ?mode=report flag is worth adding to the app: hide navigation and tooltips, disable animations, and render in a layout that prints well. It also makes it obvious to whoever changes the UI that this view has a second consumer.
One click, then a queue
The button in the app doesn't wait for the report. It creates a job and returns at once; a worker picks it up, renders the view and sends the email. That keeps the click instant and lets you control how many browsers run at the same time.
app.post("/reports/:viewId/send", requireUser, async (req, res) => {
await queue.add("send-report", { viewId: req.params.viewId, userId: req.user.id });
res.status(202).json({ status: "queued" });
});
worker.process("send-report", { concurrency: 2 }, async ({ viewId, userId }) => {
const token = await issueShortLivedToken(userId); // the report sees exactly what the user is allowed to see
const { rows, pdf } = await renderReport(viewId, token);
await mailer.send({ to: await emailOf(userId), subject: subjectFor(viewId), text: rows.join("\n"), attachments: [{ filename: "report.pdf", content: pdf }] });
});Things that matter in a bank (and everywhere else)
- Permissions. Render as the user, with a short-lived token, never with a shared super-account. The email must contain exactly what that person can already see.
- Isolation. A fresh browser context per job, so no cookies or state leak between users.
- Limits. Cap concurrency and give every step a timeout. One stuck page shouldn't block the queue.
- Observability. Log every job with its duration and outcome, and keep a screenshot when one fails. "The email didn't arrive" is easy to debug when you can see what the browser saw.
- Stable hooks. Read data through
data-*attributes made for this purpose, not through CSS classes that a redesign will rename.
When not to do this
If the report is simple and the data is already available through an API, query it directly; a browser is overkill. This pattern shines when the value is in the front end: complex views, client-side calculations, charts and formatting that people already trust.
The takeaway
The best automation often isn't a new system. It's the existing one, run by a machine, exactly the way a person would use it.