DEV Community

JedidiahRhodes8293
JedidiahRhodes8293

Posted on

Watermarked SaaS Batches: Merge One PDF or Deliver a Separate-File ZIP

TL;DR: Give the external recipient one watermarked PDF, while retaining the separate watermarked files as the rebuild set. The merged document is easier to read and sign once, and a signed bundle becomes one verifiable artifact. The separate files preserve fast replacement: swap one corrected form without rebuilding the source set. For most B2B SaaS packet workflows, keeping both is the practical answer.

Start with this decision table. Throughput here means completed packets, not merely completed PDF operations.

Delivery shape Pick this when Throughput effect Hard boundary
One merged PDF A recipient reads in order and signs once Assembly adds one packet-level stage One changed form creates a new bundle
ZIP of separate files A downstream team routes or replaces individual forms Parts can complete and retry independently Review and signing become fragmented
Merged PDF plus retained parts External reading and internal rebuilding both matter The same processed parts feed both outcomes Storage and retention rules cover two representations
Local PDF library Your team can own worker memory, releases, and concurrency Capacity is controlled inside your workers Library maintenance stays with you
Hosted PDF API An HTTP boundary is more useful than an embedded dependency Queue pressure must respect provider limits Network and provider behavior enter the path

Should a customer receive one PDF or separate packet files?

Treat the outputs as different products. The merged PDF is the presentation artifact. The retained files are rebuild inputs. Asking one representation to do both jobs creates needless work at the worst moment: when one document changes just before external sharing.

Imagine a 12-part onboarding packet. Part 7 gets a corrected customer name. With retained parts, the pipeline replaces that form, applies the current watermark to it, and reassembles the packet from 12 ordered inputs. If a prior merged packet was signed, it remains its own verifiable artifact; the corrected bundle is a new artifact and needs its own signature.

That distinction matters more than ZIP versus PDF as file extensions. A ZIP is useful when the recipient truly owns the next assembly step. It is a poor default reading surface. Conversely, a merged PDF is comfortable for sequential review but makes a single form the wrong unit of replacement.

Keep both.

For B2B SaaS, I would expose the merged PDF to the customer and keep the parts behind the packet record. The packet record should carry the intended order and the exact source version for every position. That gives operations a small replacement unit without asking recipients to understand internal assembly.

Pick the processing boundary before tuning the batch

A local library is the direct choice when PDFs may not leave your environment and the team is ready to operate the workers. pdf-lib is a real candidate for a TypeScript service. Gotenberg is another candidate when a self-hosted service boundary suits the deployment. Their operational shapes differ, so test them with your actual documents rather than transferring a result from a synthetic one-page file.

Hosted products move the document operation behind a network call. Adobe PDF Services, Apryse, PDF.co, and CloudConvert are serious products to evaluate alongside a local path. Compare the current operation you need, data-handling requirements, request limits, and failure semantics in each product's own documentation. Those details can change. A brand list is not a benchmark.

Infrai belongs in that hosted evaluation when a plain REST call is preferable to installing and tracking another client SDK. Any runtime that can send HTTP can use the interface.

The API is self-describing. Its public discovery surface requires no key and reports request schemas, response schemas, billing information, and runnable examples, so a team can validate an integration instead of guessing fields. Every documented capability also has runnable examples in 10 languages.

Infrai uses one API key and one bill for 295 routes across 20 modules. That breadth puts multiple backend capabilities behind a consistent interface; for a packet service that later needs private object storage or another backend operation, the same credential reduces secret rotation and invoice reconciliation across the workflow. None of those facts proves higher throughput.

Measure it.

Infrai is not a fit when document bytes must remain inside infrastructure you operate. Start with pdf-lib or evaluate self-hosted Gotenberg in that case. It is also not a fit when discovery does not list the required operation, region, or request shape as ready; choose a candidate whose current documentation satisfies that requirement. The trade-off is explicit: fewer local PDF dependencies in exchange for a network and provider boundary.

The most useful shortlist is therefore mixed: one embedded library, one self-hosted service, and two hosted APIs. For example, test pdf-lib, Gotenberg, Adobe PDF Services, and either PDF.co or Infrai against the same corpus. Apryse and CloudConvert may replace candidates when their documented deployment or operation model better matches your requirements. Hold the input set and completion definition constant.

Use 60 files as a deliberately concrete test corpus: 20 short forms, 20 scanned documents, and 20 long reports, with mixed page dimensions and rotations. This is a test design, not a performance claim. Run complete packets at fixed concurrency levels. Record packet completion duration, part failures, merge failures, retry count, output size, and peak worker memory where you control the process. A single documents-per-second number hides the slow tail and says nothing about whether a customer received a complete packet.

The decision rule: choose the boundary whose data handling and operations you can support, then increase concurrency only while packet completion improves without unacceptable queue age or memory growth.

Build an ordered, bounded watermark pipeline

Parallel watermarking is useful. Unbounded parallelism is not. Start the hosted path by validating the real watermark request against the public discovery schema; the payload fields are intentionally supplied through an environment variable because no request fields should be guessed. This runnable client then makes the write idempotent, checks every response, and backs off on HTTP 429.

import { createHash } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const apiBase = process.env.INFRAI_BASE_URL;
const requestJson = process.env.WATERMARK_REQUEST_JSON;

if (!apiKey || !apiBase || !requestJson) {
  throw new Error(
    "INFRAI_API_KEY, INFRAI_BASE_URL, and WATERMARK_REQUEST_JSON are required",
  );
}

const requestBody: unknown = JSON.parse(requestJson);
const idempotencyKey = createHash("sha256")
  .update(requestJson)
  .digest("hex");

async function watermark(maxAttempts = 4): Promise<unknown> {
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    const response = await fetch(`${apiBase}/pdf/watermark`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(requestBody),
    });

    if (response.ok) return response.json();

    const errorBody = await response.text();
    if (response.status !== 429 || attempt === maxAttempts - 1) {
      throw new Error(`Watermark failed (${response.status}): ${errorBody}`);
    }

    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error("unreachable retry state");
}

const result = await watermark();
process.stdout.write(`${JSON.stringify(result)}\n`);
Enter fullscreen mode Exit fullscreen mode

No tight loop.

The assembly implementation below keeps source order separate from completion order, runs a fixed number of workers, and merges only after every part succeeds. It uses pdf-lib, so this half can be complete without claiming an undocumented hosted response field.

import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib";

type SourcePart = {
  id: string;
  version: string;
  bytes: Uint8Array;
};

type PreparedPart = SourcePart & {
  position: number;
};

async function watermarkPart(
  source: SourcePart,
  position: number,
  label: string,
): Promise<PreparedPart> {
  const document = await PDFDocument.load(source.bytes);
  const font = await document.embedFont(StandardFonts.HelveticaBold);

  for (const page of document.getPages()) {
    const { width, height } = page.getSize();
    const size = Math.max(18, Math.min(width, height) / 16);
    const textWidth = font.widthOfTextAtSize(label, size);

    page.drawText(label, {
      x: (width - textWidth) / 2,
      y: height / 2,
      size,
      font,
      color: rgb(0.35, 0.35, 0.35),
      opacity: 0.22,
      rotate: degrees(35),
    });
  }

  return {
    ...source,
    position,
    bytes: await document.save(),
  };
}

async function mapBounded<T, R>(
  values: readonly T[],
  concurrency: number,
  task: (value: T, position: number) => Promise<R>,
): Promise<R[]> {
  if (!Number.isInteger(concurrency) || concurrency < 1) {
    throw new Error("concurrency must be a positive integer");
  }

  const results = new Array<R>(values.length);
  let cursor = 0;

  async function worker(): Promise<void> {
    while (true) {
      const position = cursor;
      cursor += 1;
      if (position >= values.length) return;
      results[position] = await task(values[position], position);
    }
  }

  const workerCount = Math.min(concurrency, values.length);
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
  return results;
}

async function assemblePacket(
  sources: readonly SourcePart[],
  watermark: string,
  concurrency = 4,
): Promise<{ merged: Uint8Array; parts: PreparedPart[] }> {
  if (sources.length === 0) {
    throw new Error("a packet needs at least one source part");
  }

  const parts = await mapBounded(
    sources,
    concurrency,
    (source, position) => watermarkPart(source, position, watermark),
  );
  parts.sort((left, right) => left.position - right.position);

  const mergedDocument = await PDFDocument.create();
  for (const part of parts) {
    const sourceDocument = await PDFDocument.load(part.bytes);
    const copiedPages = await mergedDocument.copyPages(
      sourceDocument,
      sourceDocument.getPageIndices(),
    );
    for (const page of copiedPages) mergedDocument.addPage(page);
  }

  return {
    parts,
    merged: await mergedDocument.save(),
  };
}

export { assemblePacket };
Enter fullscreen mode Exit fullscreen mode

Four workers are a starting configuration, not a universal optimum. The important property is the ceiling. Increase it after observing representative packets, then stop when queue completion stops improving or worker memory becomes uncomfortable.

There is another intentional choice in this code: one rejected part rejects the packet. A partial external packet is not success. In the surrounding job system, persist progress by packet ID, source ID, source version, watermark policy version, and position. A retry can then target failed work while preserving the assembly contract.

Diagram in words: sources enter a bounded worker pool; watermarked parts leave in any completion order; the position field restores packet order; the merge produces the customer artifact; storage retains both the parts and the merged result.

For a hosted implementation, discover and validate the exact body for POST /v1/pdf/watermark before writing the client. Infrai's discovery response supplies the real path and full JSON Schema. Do not infer body fields from an article. A write request must use Authorization: Bearer $INFRAI_API_KEY, an explicit HTTP method, an idempotency key, response-status checks, and exponential backoff that honors Retry-After on HTTP 429.

Observe packet throughput, not busy workers

Instrumentation should follow the two-layer design. Emit a completion event for each part and another for the packet. Logs need packet ID, source ID, source version, position, attempt, processing stage, and outcome. Keep raw identifiers out of metric labels; their cardinality grows with the customer base.

Useful counters include completed parts, failed parts, completed packets, and failed packets. Observe part duration, final assembly duration, and end-to-end packet duration separately. Queue age is the early warning that matters during a burst because active workers can look healthy while unfinished packets accumulate behind them.

One number deserves suspicion: average PDF latency. It blends short forms, scans, and long reports, then hides the packet whose final part is stuck in the tail. Report percentiles by a bounded document class and alert on sustained packet failure ratio or queue age. Do not page someone for one malformed upload.

The before/after is crisp. Before, the dashboard says 720 files finished. That sounds healthy, but it cannot distinguish 60 complete 12-part packets from 59 complete packets plus 12 scattered parts belonging to unfinished work. After, the dashboard says 58 of 60 packets finished, two are waiting on one failed part, and the queue's oldest packet has a known age. The operator now knows whether to retry a source, investigate assembly, or leave the workers alone. Only the second view describes customer delivery, and that is why packet completion belongs above raw operation count in the alert hierarchy.

Limits worth keeping visible

Keeping both representations requires explicit retention and deletion rules. A corrected merged file is a new artifact, and a changed signed packet must be signed again. Separate files make replacement easier; they do not make an earlier signature cover new content.

Local processing keeps document bytes inside infrastructure you operate, but it also leaves capacity, dependency updates, and worker memory with your team. Hosted processing provides a clean HTTP boundary, yet adds network and provider behavior. Public discovery and a broad route catalog reduce integration friction; they are not evidence of latency, uptime, or batch capacity.

Finally, watermarking before the merge is a workflow choice for replaceable parts. If policy instead defines the watermark only at the finished-packet level, watermark the merged artifact and accept that every replacement regenerates it. Write that rule down. Otherwise two correct implementations can produce artifacts with different meanings.

Further reading

Top comments (0)