Skip to main content

INFRA-616 — Server-side Work Order PDF rendering: spike findings

Time-boxed investigation for INFRA-616. The design these findings validate is written up in work-order-pdf-export-design.md.

Verdict: the approach works. Build INFRA-617 as designed, with two simplifications — no translation shim module is needed, and images can be inlined as data URIs rather than served from a local URL.

Harness: scripts/spike-render-wo-pdf.mts (renderer + measurements) and scripts/spike-export-wo-fixture.mts (dump a real PO's render payload to JSON). Nothing here is production code; the branch is not intended to merge.


1 · SSR of the existing template — works, zero component changes

renderToStaticMarkup(WorkOrderMainDisplay) renders the real component tree. Translations resolve, the stored barcode SVG passes through inline, product image <img> tags are emitted.

The only requirement is initialising i18next before the first render:

await i18next.use(initReactI18next).init({
lng: 'zh-CN', fallbackLng: 'en', interpolation: { escapeValue: false },
resources: { en: { translation: en }, 'zh-CN': { translation: zhCn } },
});

AdminJS's useTranslation delegates to react-i18next, and translateComponent(name) resolves under the components namespace of the same locale JSON admin.router.ts already registers. No shim module, no module aliasing, no component refactor — the fallback plan of pointing Chromium at the live AdminJS page is not needed.

Entry point is WorkOrderMainDisplay, not WorkOrderTemplate: the latter owns data fetching, loading states and i18n.language detection, none of which apply server-side.

⚠️ The trap that hides this

Run the render from a real ESM module (.mts). Under CJS — which tsx -e produces — a second copy of react-i18next is loaded (its CommonJS build) while AdminJS uses the ESM build. initReactI18next then registers the instance on a copy AdminJS never sees, and rendering dies with TypeError: i18n.t is not a function, preceded by the misleading warning react-i18next:: You will need to pass in an i18next instance by using initReactI18next — which reads like the init was forgotten rather than duplicated. Cost me the first hour.


2 · Image dedupe — Chromium dedupes, and data URIs are safe

This was the open question worth ~4× on Aolong's file size. Answered: repeated data URIs are deduped exactly like shared URLs.

500 synthetic Aolong work orders, 110 distinct images, 1,000 <img> elements:

--images modePDF sizeEmbedded JPEG objects (/DCTDecode)
inline (data URI)17.18 MB110
url (shared local URL)17.18 MB110

Byte-identical totals, and exactly one embedded object per distinct image rather than one per reference. Milly's header-only pages embed 0, confirming the count tracks real images rather than an artifact.

So INFRA-617 should inline downscaled data URIs — simpler than standing up a local server to hold image bytes during a render, with no size penalty.


3 · Page-break parity — break-after: page matches today exactly

Today the browser measures each [data-id="workorderContainer"] and pads its height up to a multiple of 1123px (A4 at 96dpi), so a work order occupies ceil(height / 1123) pages. Server-side that is replaced by:

.wo-page { break-after: page; page-break-after: always; }
.wo-page:last-child { break-after: auto; page-break-after: auto; }

The harness verifies this on every run rather than by eyeballing: it measures each work order's rendered height in the browser, computes ceil(height / 1123), and compares the sum against the PDF's actual page count.

CaseTallest work orderExpected pagesActual pages
Aolong, 12 measurement rows< 1123px1 per WO✅ match
150 measurement rows1669px2 per WO✅ match
300 measurement rows2883px3 per WO✅ match
Milly, header only< 1123px1 per WO✅ match

Multi-page work orders — the case where the two approaches could plausibly diverge — match too. Note that the measurements table splits into two columns, so it takes ~150 rows before a work order needs a second page; the real libraries are far below that.


4 · Render-unit size is a three-way trade-off — and it changes file size

The surprise of the spike. Dedupe is per-document, so splitting a 500-work-order delivery file into smaller render units re-embeds images and font subsets in every document. Smaller units cost bytes; larger units cost memory.

500 synthetic Aolong work orders in the Alpine container, lang=zh:

--unitDocumentsPDF totalPer work orderPeak browser RSSWall-clock
252026.56 MB54.4 KB1,015 MB34.0s
501024.47 MB50.1 KB1,082 MB17.3s
250219.95 MB40.9 KB1,605 MB18.3s
500119.42 MB39.8 KB2,364 MB24.0s

Chromium's floor is ~1 GB no matter how small the unit, so no unit size makes a 1 GB instance viable. Wall-clock is U-shaped: tiny units pay per-document overhead, huge ones pay for one enormous layout.

Recommendation: render unit = delivery unit = 500, at 4 GB. It produces the smallest files, needs no merge step and no PDF-merging dependency. Rendering at 50 and merging would halve memory but inflate output ~26% and need a merge that won't re-dedupe across documents.

⚠️ Per-PO wall-clock must be derived from the per-factory rates below, not from this table. The table above is Aolong work orders, and the two factory shapes differ by ~4× (39.8 KB vs 8.4 KB per work order), so their rates are not interchangeable. Sequentially:

  • Aolong — 24.0s per 500, so ~855 work orders (2 chunks) ≈ 41s
  • Milly — 6.1s per 500, so 7,135 work orders (15 chunks) ≈ 92s

An earlier version of this paragraph applied Aolong's 24s rate to Milly's chunk count and concluded "about 6 minutes" for Milly — 4× too high. That figure propagated into INFRA-621's ticket description before being caught, so it is called out here rather than quietly corrected.

Note the 4 GB sizing is unchanged by hosting, but the where is: INFRA-621 replaced the DigitalOcean worker with a Cloud Run job. See infra-621-cloud-run-hosting-decision.md.

Per-factory sizes (container, unit=500)

Milly shape (header + barcode)Aolong shape (+ measurements, notes, 2 images)
Per work order8.4 KB39.8 KB
500-page PDF4.12 MB19.42 MB
Zip of that PDF1.08 MB (26%)3.40 MB (20%)
Wall-clock6.1s24.0s

Extrapolated to the real POs — both well below the design-doc estimates:

Work ordersFilesPDF totalZipDesign doc said
Milly PO231827,13515~60 MB~16 MB~125 MB zip — ~8× too high
Aolong PO232248552~34 MB~7 MB~21 MB zip — ~3× too high

Storage revised down to ~1.5 GB steady state (Milly ~330 MB/month, Aolong ~1.2 GB/month, both counting zip + loose PDFs, 30-day lifecycle) versus 2.4 GB in the design doc.

✅ Confirmed against real production densities

The row counts were queried from production on 2026-07-29 and differ from my first assumptions in both directions — but they cancel out, so every size figure above holds:

AssumedReal (production)
Measurement rows per work order127 median (2 min, 10 max)
Material library rows15 across 3 categories54 across 7 categories

Re-measured at real density: 19.42 MB per 500-page file, 39.8 KB per work order — identical.

⚠️ But Aolong work orders sit 16px from a page break

At real density an Aolong work order renders 1107px tall against a 1123px A4 page — a 1.4% margin. The threshold probe:

MeasurementsMaterial rowsHeightPages per work order
7 (median)54 (today)1107px1
10 (observed max)541124px2
758 (+4 rows)1172px2
7621237px2

Two consequences:

  1. Some Aolong work orders already spill onto a second page today. Styles at the top of the measurement range (10 rows) cross the boundary. This is pre-existing behaviour — today's browser flow pads to whole pages the same way — not something the server-side change introduces.
  2. Adding ~4 rows to the shared Material library doubles every Aolong page count. The library is global and editable from AdminJS, and every one of its rows renders on every work order (makeMaterialCategorys filters nothing). Four more rows turns a 500-page file into ~1000 pages, roughly doubling file size, render time and peak memory.

For INFRA-617: don't hardcode or assume one page per work order anywhere, and budget storage and memory for up to 2× the figures above. The chunk-to-file mapping is by work order count (500), not page count, so the file structure itself is unaffected. Worth flagging to whoever curates the Material library that it now has a downstream cost.


5 · Alpine, CJK fonts and memory

Chromium runs. Chromium 150.0.7871.128 Alpine Linux, headless, via puppeteer-core and PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser. Needs --no-sandbox (no user namespace as root in a container) and --disable-dev-shm-usage.

⚠️ Image cost is much higher than expected: the apk layer is 821 MB, taking the whole image from ~225 MB to ~1.05 GB. That is a real deploy-time and registry cost, especially given the registry's GC constraints. Since only the worker needs Chromium, consider a separate worker image — the design's "same image, different run command" means the web service carries the 821 MB too, for nothing.

CJK renders. font-noto-cjk installs ~30 font files. Verified by rendering the same 20 work orders twice: lang=en → 188,196 bytes, lang=zh → 309,833 bytes. A 65% increase is the signature of a real CJK glyph subset being embedded; tofu boxes would add almost nothing.

A nuance worth keeping: fc-match ":lang=zh" returns FreeSans, not Noto CJK, so the CJK font is reached through Chromium's per-glyph fallback rather than as fontconfig's default. It works, but it means the font stack is sensitive to changes in the installed font packages — if someone trims ttf-freefont or font-noto-cjk later, verify CJK output again rather than assuming.

Layout is stable across platforms. The same 500 work orders measured 553px tall under macOS Chrome and 555px under Alpine Chromium — 0.4% drift, and identical page counts. Only a work order sitting within a couple of pixels of a page boundary could flip, which the parity check in §3 would catch.

Worker sizing: 4 GB. Peak RSS was 2,364 MB for Aolong at unit=500 and 1,753 MB for Milly. 2 GB would be too tight; 1 GB is impossible at any unit size.


6 · What still needs real production data

Everything above except §4's absolute sizes is settled. Remaining:

tsx scripts/spike-export-wo-fixture.mts --po PO23182 --limit 500 --out ./fixture-milly.json
tsx scripts/spike-export-wo-fixture.mts --po PO23224 --limit 500 --out ./fixture-aolong.json
tsx scripts/spike-render-wo-pdf.mts --fixture ./fixture-aolong.json --unit 50 --zip

markPrinted: false is hardcoded in the exporter, so it cannot move work orders to Printed. One latent write remains: makeBatchInfosByPoId_makeBatchBarcodeAndSave early-returns when every work order already has a valid barcode, but will generate and persist any missing ones.


7 · Recommendations for INFRA-617

  1. Inline downscaled images as data URIs. Dedupe is automatic; skip the local-server design.
  2. Declare react, react-dom and puppeteer-core in dependencies. react/react-dom are currently undeclared, arriving transitively through adminjs (both are already in vite.config.ts's external list). Production code importing react-dom/server should not rely on a transitive hoist.
  3. Pin archiver to ^7. v8 ships a class-based ESM-only API (ZipArchive) that does not match its own published @types/archiver, and has no CJS entry. v7's archiver('zip', …) factory is what the ecosystem and the types expect.
  4. The renderer must be a real ESM module for the react-i18next reason in §1.
  5. Render one document per 500-work-order delivery file, at 4 GB (§4). Avoids a merge step and a PDF-merging dependency, and produces the smallest output. SSR itself is trivial (~10 ms per document); nearly all wall-clock is inside page.pdf(). Derive per-PO timings from the per-factory rates — Aolong 24.0s/500, Milly 6.1s/500 — never from one shared figure.
  6. Give the renderer its own image if the 821 MB Chromium layer on the web service is unwelcome (§5). INFRA-621 made this unconditional: the renderer is a Cloud Run job with its own Dockerfile.renderer, so the layer never reaches the MES deploy pipeline at all.
  7. font-noto-cjk is required in the image, not optional — without it every Chinese style and colour name becomes tofu boxes.
  8. For local GCS testing, copy the sibling service's emulator. inventory-integration-service already runs fsouza/fake-gcs-server (port 4443, data persisted under .emulator-datas/) in its compose file. Same pattern here means INFRA-617 can be developed and tested without real GCP credentials — worth adding to this repo's compose.yaml alongside db.

8 · The design these findings validate

Written up separately in work-order-pdf-export-design.md — the architecture and job-lifecycle diagrams, the delivery and expiry rules, the measured sizing, and the decision log with rationale. Kept there rather than duplicated here so there is one place to maintain.