Short answer: keep the weekly schedule, report data, and HTML-to-PDF rendering behind three separate contracts, then let one idempotent job compose them.
The deciding constraint is template ownership. If developers own the HTML template, a Node.js service can validate data, render markup, produce a PDF, and store it without asking another system to interpret business rules. If designers or customers own the template, that same convenient pipeline becomes a deployment bottleneck. The code can stay small. The ownership decision can't be patched over later with another config file.
This build log uses an Express trigger and a weekly cron adapter, but neither gets to know how the report looks. That's deliberate. A scheduler should say which reporting period is due; it shouldn't carry HTML, choose filenames, or retry half a document bundle.
Why do a Node.js Express cron job and HTML template need separate PDF boundaries?
A weekly report sounds like one function until the first rerun. Then several questions appear at once: Did the cron job already start this period? Which template revision produced the existing PDF? Can an operator regenerate one report without waiting a week? Does splitting a bundle preserve the intended page order? Those are state and ownership questions, not rendering questions.
I use three boundaries:
-
ReportSourceowns the normalized data for a reporting period. -
TemplateRendererowns HTML creation and the template revision. -
PdfRendererowns the conversion from complete HTML to PDF bytes.
Storage and scheduling sit outside those three because they are delivery mechanics. This makes the dependency direction boring — a compliment in developer tooling. The cron adapter calls the same use case as an authenticated Express route, and the use case writes through one storage interface. There is no second “manual generation” implementation waiting to drift.
The split also makes document bundles less mysterious. Treat a merged weekly bundle as an ordered list of rendered report artifacts, not as a giant template with conditional page fragments. Treat a split as an index operation over an already generated bundle, with explicit output names and page ranges. PDF is standardized as ISO 32000-2, but that does not make arbitrary HTML layout portable. The renderer is still a real system boundary, so its output deserves fixture checks rather than faith.
Template ownership changes the best placement of that boundary:
| Owner | Where the template belongs | Main trade-off |
|---|---|---|
| Developers | Versioned beside application code | Review and rollback are straightforward; every wording change needs a deployment. |
| A design team | A separately versioned template package | Visual work can move independently; package compatibility becomes part of release control. |
| Customers | A constrained template service or stored schema | Customization improves; validation, isolation, and preview behavior become product features. |
Don't turn the last row into “accept arbitrary HTML.” That is not template ownership. It is execution and resource policy disguised as a text field.
The smallest implementation I would ship
The useful minimum is not a route with a PDF call inside it. It is one use case with a deterministic job key, injected boundaries, and two thin entry points. The following TypeScript leaves the renderer and persistence mechanisms replaceable on purpose; concrete adapters can use whichever components satisfy the deployment's layout, security, and operations requirements.
import express, { type Request, type Response } from "express";
type Week = Readonly<{ start: string; end: string }>;
type WeeklyReport = Readonly<{
team: string;
period: Week;
shipped: readonly string[];
incidents: number;
}>;
type RenderedHtml = Readonly<{
html: string;
templateRevision: string;
}>;
interface ReportSource {
load(period: Week): Promise<WeeklyReport>;
}
interface TemplateRenderer {
render(report: WeeklyReport): Promise<RenderedHtml>;
}
interface PdfRenderer {
fromHtml(html: string): Promise<Uint8Array>;
}
interface ReportStore {
putOnce(input: {
key: string;
bytes: Uint8Array;
metadata: Record<string, string>;
}): Promise<"created" | "exists">;
}
type Dependencies = Readonly<{
source: ReportSource;
templates: TemplateRenderer;
pdf: PdfRenderer;
store: ReportStore;
}>;
const weekKey = (period: Week): string =>
`weekly-report/${period.start}_${period.end}.pdf`;
async function generateWeeklyReport(
dependencies: Dependencies,
period: Week,
): Promise<{ key: string; status: "created" | "exists" }> {
const report = await dependencies.source.load(period);
const rendered = await dependencies.templates.render(report);
const bytes = await dependencies.pdf.fromHtml(rendered.html);
if (bytes.byteLength === 0) {
throw new Error("PDF_EMPTY");
}
const key = weekKey(period);
const status = await dependencies.store.putOnce({
key,
bytes,
metadata: {
periodStart: period.start,
periodEnd: period.end,
templateRevision: rendered.templateRevision,
},
});
return { key, status };
}
function parseWeek(request: Request): Week {
const start = String(request.body?.start ?? "");
const end = String(request.body?.end ?? "");
if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || !/^\d{4}-\d{2}-\d{2}$/.test(end)) {
throw new Error("INVALID_WEEK");
}
return { start, end };
}
export function buildReportRouter(dependencies: Dependencies) {
const app = express();
app.use(express.json({ limit: "16kb" }));
app.post("/internal/reports/weekly", async (request: Request, response: Response) => {
try {
const result = await generateWeeklyReport(dependencies, parseWeek(request));
response.status(result.status === "created" ? 201 : 200).json(result);
} catch (error) {
const code = error instanceof Error ? error.message : "GENERATION_FAILED";
response.status(code === "INVALID_WEEK" ? 400 : 422).json({ code });
}
});
return app;
}
interface WeeklyScheduler {
everyWeek(task: (period: Week) => Promise<void>): void;
}
export function registerWeeklyJob(
scheduler: WeeklyScheduler,
dependencies: Dependencies,
): void {
scheduler.everyWeek(async (period) => {
await generateWeeklyReport(dependencies, period);
});
}
The interfaces are short, but the behavioral contracts need teeth. ReportSource.load returns normalized data for an explicit closed period. TemplateRenderer.render returns both HTML and a stable template revision. ReportStore.putOnce makes a repeated schedule delivery harmless for the same key. The route returns 201 for a new artifact and 200 when the artifact already exists; a caller can distinguish work from replay without parsing a log line.
Notice what is absent: no timezone is hidden in the use case, no destination path comes from the request, and no cron expression is copied into application logic. The scheduler adapter must calculate the period under one declared timezone and pass it in. I'm not sure which timezone your report should use because the query cannot answer that; the business definition of “week” must. Resolve it before implementing the adapter, then test the daylight-saving transition if that timezone observes one.
The 16 KB request limit is intentionally mundane. This route accepts two dates, not a template or a report payload. Config bloat often begins when an internal trigger quietly becomes a second data-ingestion API.
Keep it dull.
Test the artifact, not just the status code
A 201 only proves that the control path completed. It says nothing about clipped tables, missing glyphs, wrong page order, or a footer covering the final row. For a developer-owned template, I would keep a tiny fixture report beside the template and run the same renderer contract in CI. Check that the output is non-empty, record its page count through the PDF adapter, and extract a few semantic anchors such as the team name and period. A byte-for-byte snapshot is usually too sensitive to metadata and renderer changes; the test should defend the document's meaning and page geometry.
Bundle operations need their own invariants. A merge input is ordered, so the test fixture should prove that report A precedes report B. A split request should reject overlapping, reversed, or out-of-range page intervals before touching storage. Use output keys derived from the source artifact plus the requested interval, which gives retries the same idempotency property as weekly generation. These checks are where I spend benchmark effort too: collect render duration, input HTML bytes, output PDF bytes, and page count per job, then compare changes against a checked-in fixture corpus. I wouldn't publish a universal latency target. Your mileage may vary with fonts, images, page count, and the renderer adapter, and a local measurement is the evidence that matters.
Observability should follow the same boundaries. Emit one job identifier and attach the reporting period, template revision, artifact key, duration, byte count, and final state. Do not log the full HTML or report data by default. When a render fails, preserve the error category at the adapter boundary — invalid input, template rendering, PDF conversion, or storage — so an operator can retry the right unit instead of rerunning the entire week blindly.
One awkward case deserves an explicit policy: the report data can change after a PDF has been stored. putOnce chooses reproducibility over silent mutation. If corrections are allowed, generate a new revisioned key and retain the original metadata rather than overwriting history under the same name. That's more storage, but it makes “which numbers did we send?” answerable.
What I would change at scale, and when this design is wrong
At low volume, the Express route may await generation because the operation is easy to inspect and deploy. At scale, I would move the use-case call behind a durable queue, cap renderer concurrency, isolate temporary working directories, and keep the HTTP route limited to validation plus job creation. The contracts above do not change. The worker receives a period and job key, while status lives in durable state rather than in one process's memory.
I would also separate merge and split work from report rendering once bundles grow large. Each operation gets an immutable manifest: ordered source artifact keys for merge, or validated page intervals for split. This avoids sending PDF bytes through the scheduler message and gives an operator something compact to inspect. Benchmark before selecting worker size or concurrency. Guessing from the number of reports is weak because one image-heavy report can dominate a batch.
The catch is template ownership. This code-first design is not suitable when non-developers must edit layouts frequently, customers need per-tenant branding, or legal text changes outside the application release cycle. In those cases, keep the orchestration contracts but move template management behind a constrained, versioned interface with preview and approval. Stick with versioned templates in the repository when developers truly own copy and layout, changes travel through normal review, and deterministic rollback matters more than self-service editing.
There is another boundary: high-fidelity accessibility, archival profiles, digital signatures, or regulated retention can make a generic HTML conversion step insufficient. The correct choice then starts with the required PDF conformance and validation process, not with the cron library. ISO 32000-2 defines the PDF specification; it does not certify that a particular generated document meets every domain-specific obligation.
The decision rule is plain. Own the template where its actual editors can version and review it, keep scheduling ignorant of presentation, and make generation replayable by construction. Everything else — Express, a cron adapter, a queue, or a renderer — is replaceable plumbing.
Top comments (0)