Hosting the artboard renderer
How the renderer is hosted, what it measures, and what is left before it serves MCP tools or editor exports. How it works: spec. Infrastructure facts come from Kittl/infrastructure, Kittl/development-applications-config and Kittl/platform-applications; prices and limits from Sources. UNVERIFIED marks what no source confirms.
Why not a Worker: @napi-rs/canvas is a native Node addon, and deployed Workers cap an isolate at 128 MB [L] (Durable Objects too [DL]), less than one render needs (§2.7).
What v0 promises: a supported design gets its file, with x-render-report listing any asset that didn't load, or a meaningful error (400, 413, 422, 504) within the limits below. Overload is explicit: 503 with retry-after, never an unbounded wait. Jobs aren't durable: a request lost with its container (out of memory) gets a 503 from the Worker, within 45 s; an upload stalled for 15 s is reset without a status (408 only on direct container access, §7); the caller retries both a bounded number of times.
1. Two workloads, two verdicts
| Workload | Verdict | Cost/month (§3) |
|---|---|---|
| MCP: a model reads a project, then edit → re-render one board → check | Viable now, with the small changes in §7 | ~$60–80 up to 1M renders |
| Editor export, paywall-gated, seconds of latency acceptable | Not viable as-is; viable with §1.1 | ~$700–800 for all exports; ~$115 for free and trial plans only |
Live editing is not a target. Hosted figures are measured on staging (§2.2) unless marked est.: a Cloudflare cold start, and exports above 2048 px, scale native arm64 by the slower instance's ×2–3. The cost model keeps native × 1.75, close to the measured pool average.
| Phase | Where | Gap |
|---|---|---|
| 1 (§5) | Cloudflare Containers: this Worker checks the Keycloak token, rate-limits and forwards to 2 containers. All in mono. | No autoscaling |
| 2 (§6), only if the data says so | The same image on Kittl's EKS (common-chart, KEDA) |
Three-repo onboarding; egress makes it the most expensive host for exports (§3.2) |
1.1 What editor export needs
| # | Change | Why (§2) |
|---|---|---|
| 1 | Output cap raised to the editor's ~10,800 px, with deadline and memory scaled by output pixels | 65% of exports are over the 2048 px cap; dense text at p90 takes ~50–75 s (> 25 s deadline); p99 needs ~3.5–4.5 GiB |
| 2 | Async jobs, results written to R2 and returned as URLs | Long renders; R2 egress is free (§3.2) |
| 3 | ~12–15 vCPU at peak with least-busy dispatch, or a separate large-export pool for the top few percent | 9.3 busy vCPU at the peak minute; getRandom wastes capacity (§2.4) |
| 4 | Private assets loaded with the caller's credentials | §2.3 |
| 5 | Parity gaps closed: CMYK (1.1% of exports), MP4 (1.7%), mockup boards (in 10.3% of designs), PDF raster-image shadows, workspace fonts | spec §2, §5 |
| 6 | Double load removed | −35–50% CPU (spec §6.2) |
| 7 | Faster PNG: lower zlib level, or JPG/WebP when no transparency is needed (31.5% of PNG exports are transparent) | PNG is 85% of exports and 4–6× slower than JPG on photo boards (§2.6) |
| 8 | Paywall check in the API before queueing | Don't spend render capacity on refused exports |
2. Measured workload
The real image (container/Dockerfile), --cpus=1 --memory=4g as in wrangler.jsonc, over HTTP, on native linux/arm64 (Apple M2 Max) unless noted. Emulated linux/amd64 came out 1.2–1.8× native. Hosted figures are staging's: the Worker in front of 2 such instances, getRandom dispatch, client times from one client in Europe. Assets are fetched live from staging on every export (the response cache is per export). Times are p50 of warm runs. How to run the image: the README's Development section.
2.1 Real designs and exports
Production aggregates: designs updated in a 30-day window (20% sample), and 30 days of editor downloads.
| Metric | p50 | p90 | p99 | max |
|---|---|---|---|---|
| Boards per design (floating included) | 2 | 13 | 90 | 1,165 |
| State JSON size | 6 KB | 74 KB | 460 KB | 5.25 MB |
| Top-level objects per artboard | 1 | 13 | 59 | 4,016 |
| Artboard long side (state px) | 2,250 | 5,850 | 10,800 | |
| Export long side (px) | 3,800 | 6,238 | 10,800 | editor cap |
| Export area | 12 MP | 33 MP | 112 MP |
- Designs: 39% have 1 board, 81% at most 5. Objects: text 37%, SVG illustration 23%, basic shape 20%, raster image 18.5% (top level; groups not expanded). 51% are opened more than once.
- Export volume: 1.81M/month, ~60k/day; 1.26/s at the busiest hour, 1.9/s at the busiest minute.
- Export format: PNG 85%, JPG 8%, SVG 3%, PDF 2%, MP4 1.7%. 93% export one board.
- Export size: over 2,048 px 65%, over 4,096 px 45%, over 8,192 px 6.8%.
- Export plan: paid 87%, free 12.3%, trial 0.7%.
The test boards are heavier than the median real board (1 object), so per-board times below are pessimistic for MCP. For exports, output size dominates, not board count.
2.2 Time per board
Render time in ms (x-render-ms); hosted cells are instance A / B:
| Content | Native 512 | Native 2048 | Hosted 384 | Hosted 512 | Hosted 2048 | Peak RSS |
|---|---|---|---|---|---|---|
Vector (colors) |
73 | 188 | 97 / 269 | 87 / 193 | 244 / 478 | 272 MiB |
| Light text (18 shapes + text) | 84 | 208 | 154 / 216 | 144 / 258 | 281 / 557 | 293 MiB |
| Dense text with effects (66 objects) | 490 | 3,142 | 535 / 862 | 679 / 1,232 | 1,587 / 3,405 | 421 MiB |
| Photo grid (12 images) | 629 | 1,316 | 620 / 1,275 | 680 / 1,256 | 1,596 / 3,115 | 613 MiB |
4,500 px photos with alpha mask (cmyk-export) |
2,800 | 3,313 | 4,701 / 8,457 | 4,612 / 8,630 | 5,306 / 10,246 | 1,605 MiB |
- Two instances, two speeds: against native, A is 1.0–1.25× on photo boards (1.6× on
cmyk-export), B is 2–2.4× (3.1×); the pool averages ~1.8–2×. The split held through the whole benchmark and may change with placement. Its cause (host CPU, a noisy neighbour, throttling) isn't visible from outside:x-containerandx-cpu(README) name each instance and its host CPU to confirm it. Latency promises use B. - Light boards carry 50–150 ms of fixed overhead hosted, so their ratio to native is noise.
- First-seen large photos: the first render of
cmyk-exporton an instance takes ~2× warm (9.3 s on A, 18.3 s on B), likely a cold regional asset cache (UNVERIFIED); locally it is within ~10% of warm. - What an MCP verify call waits for (client p50, upload and edge included): ~0.3 s for text and vector boards, 0.9–1.5 s for photo boards, 5.0 s on A and 8.6 s on B for the 4,500 px photo board. The edge alone adds ~30 ms; client minus
x-total-msis 130–300 ms with the body upload. - CPU-bound: cgroup CPU ≈ wall, except boards with private assets (§2.3).
- Size barely matters below 2048 px for photo boards: loading and decoding dominate. A smaller image saves tokens, not CPU.
- Supervisor overhead: IPC of a 270 KB state in and the file out costs 0.3 ms (32 KiB out) to 4.7 ms (7 MB PDF out).
x-total-ms−x-render-msis 2–4 ms on vector boards and 55–75 ms on photo boards: the cleanup after each export (canvas shrink, forced GC). - At least 1 vCPU: renders run one at a time per process, so fractional-vCPU instances (Cloudflare
basicandlite, Flyshared-cpu) multiply these times.
2.3 Where the time goes
Instrumented image, 512 px:
| Board | Render | Migrate | Load 1 | Load 2 |
|---|---|---|---|---|
colors |
75 ms | 0.0 ms | 32 ms | 31 ms |
| Dense text | 401 ms | 0.1 ms | 59 ms | 55 ms |
| Photo grid | 599 ms | 0.1 ms | 249 ms | 213 ms |
cmyk-export |
2,827 ms | 0.3 ms | 1,425 ms | 1,397 ms |
Double load: load 2 ≈ load 1, 35–50% of render CPU. Cause and fix: spec §6.2.
Migrate: 0.1–3 ms even from a 200-board state; a 3.8 MB body adds ~45 ms in total (parse, clone, upload), ~5 ms at the p99 state size (460 KB). So narrowing the state to one board before migrating was dropped: nothing to gain.
Private assets: user uploads and edited paths on
api-objects/users/…answer 403 without the caller's session. Each such board idles +~6 s, locally and hosted, while holding the render slot, and the file is drawn without them. It is exactly the editor's image retry budget, paid once per load:FETCH_RETRIES 3 × FETCH_RETRY_DELAY 1 s × 2 loads = 6 s idle (packages/transport/src/fetchRetry/fetchRetry.ts) (spec §6.2)Removing the double load halves it; not retrying a 4xx in export mode, or loading with the caller's credentials, removes it (§7, follow-ups 1–3).
2.4 Cold start and throughput
| Native arm64 | Emulated amd64 | |
|---|---|---|
docker run → first 512 PNG |
1.3–1.6 s | 2.7–3.1 s |
| Second render | 0.10–0.13 s | 0.15–0.17 s |
Cloudflare's platform cold start, "often 1–3 s" [CF], comes on top: a sleeping instance costs ~2.5–6 s (est.) before its first render. Staging never showed one: both instances stayed warm (sleepAfter 1 h). To measure it, leave staging idle for over an hour and time one request.
Throughput of 2 containers with random dispatch, 14 mixed test boards at 512 (one with a private asset), closed loop, 42 requests per level:
| In flight | Native | Hosted |
|---|---|---|
| 1 | 0.95/s | 0.50/s |
| 2 | 1.14/s | 0.73/s |
| 4 | 1.27/s | 0.82/s |
| 8 | 1.94/s, 13 × 503 | 1.16/s, 8 × 503 (queue full) |
- Hosted pool capacity: ~0.8–1.2 renders/s, ~0.6× native.
- One native 1-vCPU container: ~1 render/s on this mix, ~1.5/s without the private-asset board; text and vector boards 10–13/s, photo-heavy ~0.35/s.
- Overload: above 5 requests per container, 503 at once; queued requests wait up to 8–9 s (p95), under the 10 s limit, locally and hosted.
- Dispatch: natively, 2 containers with 2 in flight: random choice like
getRandomreached 1.14 renders/s, least-busy (2 in flight per container) 1.8/s. Least-busy scales linearly with containers, and would also steer work away from the slower instance. Hosted, random dispatch ran at ~62% efficiency on a 100-board design (§2.5).
2.5 Large designs
Rendering every board, hosted: ~1.3 s per board on the 2-instance pool, plus 2.5–6 s (est.) if instances slept. Measured on a 100-board design at 512, 4 in flight: 130 s, no 503. Its 160 s of render time would take 80 s perfectly balanced: random dispatch and the slower instance ran at ~62% efficiency (native, least-busy: 55 s).
| Design | Boards | 2 (configured) | 10, random (est.) |
|---|---|---|---|
| p50 | 2 | ~2 s | ~2 s |
| p90 | 13 | ~17 s | ~4 s |
| p99 | 89 | ~115 s | ~23 s |
| max | 1,165 | ~25 min | ~5 min |
A 256 px contact sheet saves only ~10% of render time: for big designs it saves tokens, not time (spec §1.5).
2.6 Large exports
Measured up to 8,192 px with the cap raised. Linear fits (native, PNG):
photo 0.55 s + 0.17 s/MP
photo-heavy, alpha mask 2.8 s + 0.12 s/MP
dense text effects 0.75 s/MP (36 MP: over the 25 s deadline)
light vector 0.04 s/MP (measured to 4 MP)
What a user would wait for, hosted (est.: the fits × 2–3, the slower instance's factor), plus transfer of a ~5 MB file:
| Export | Light vector or text | Photo | Photo-heavy, masks | Dense text effects |
|---|---|---|---|---|
| p50, 12 MP | ~1–1.5 s | ~5–8 s | ~8.5–13 s | ~18–27 s |
| p90, 33 MP | ~2.5–4 s | ~12–18 s | ~13.5–20 s | ~50–75 s |
| p99, 112 MP | ~9–13 s | ~40–60 s | ~32–49 s | ~170–250 s |
- Hosted at 2048 (measured, the deployed cap): PNG on photo boards renders in 1.8–3.1 s, JPG in 0.8–1.3 s. On
cmyk-exportthe format barely matters: loading the 4,500 px photos dominates. - PNG is 4–6× slower than JPG at the same size on photo boards (67 MP photo grid: 12.0 s vs 2.1 s). It is the only difference between the runs, but UNVERIFIED as pure encode time.
- Memory:
cmyk-exportat 8,192 px reached 2.5 GiB; the 112 MP p99 extrapolates to ~3.5–4.5 GiB (not measured).
2.7 Memory
- Idle: about 175–205 MiB cgroup after boot and one render, supervisor and render process together; the supervisor holds no editor code.
- Per render: peak RSS by content in §2.2; 272 MiB for vectors to 1,605 MiB for 4,500 px photos.
- Memory stays high after heavy renders: V8 doesn't count napi's native pixels, so it frees them late, even with the canvas shrink and forced GC after each export. 144 renders of six fixtures peaked at 1,709 MiB cgroup.
- Hosted, at 2048: RSS after a
cmyk-exportjob is 1.4–1.56 GiB; the render process's high-water mark ~1.9 GiB of the 4 GiB instance, which kept over 2 GiB of headroom and failed no request. Above 2048 is unmeasured hosted. - Memory guard (
container/supervisor.ts): after a render, the supervisor replaces the render process after 500 renders or above 1.75 GiB RSS (RECYCLE_AFTER_RENDERS,RECYCLE_ABOVE_RSS_MIB). It happens between renders: queued requests wait for the new process (about 1 s to load the editor) and nothing gets 503.
Known risk: a heavy render right after the check. The guard checks RSS only after a render:
render n ends → RSS 1.74 GiB < 1.75 → keep serving
render n+1 → cmyk-export: + about 1.4 GiB → about 3.1 GiB, under the 4 GiB limit
render n+1 ends → RSS > 1.75 → recycle (too late if the platform already OOM-killed it)
On out-of-memory, Cloudflare restarts the instance without swap [CF], so the running and queued renders on it fail (the Worker answers 503). If OOM kills show up, in order:
| # | Mitigation | Change |
|---|---|---|
| 1 | Lower RECYCLE_ABOVE_RSS_MIB to about 1,536 |
An env var; recycles more often |
| 2 | Remove the double load (both canvases are alive at the peak) | spec §6.2 |
| 3 | Bigger instance: standard-2, 6 GiB |
wrangler.jsonc, a cost increase |
3. Cost
Cloudflare Containers [CP]: CPU $0.00002 per vCPU-s, active only; memory $0.0000025 per GiB-s and disk $0.00000007 per GB-s while the instance runs (sleepAfter stops it); egress $0.025/GB in North America and Europe after 1 TB; Workers Paid $5. No autoscaling: max_instances is fixed [CF].
3.1 MCP
1.14 vCPU-s per render hosted (native × 1.75), ~150 KB PNG. 2 × 1 vCPU / 4 GiB, Workers Paid included:
| Renders/month | Always on | Per 1k renders | Awake 60% (sleepAfter, quiet nights) |
|---|---|---|---|
| 10k | $59 | $5.90 | $37 |
| 100k | $61 | $0.61 | $39 |
| 1M | $82 | $0.08 | |
| 3M | $128 | $0.04 |
- Fixed: memory, $27 per instance per month always on. Marginal: $0.023 per 1k renders, ≈ $0.00002 per render.
- Tokens cost more: one 512 px image costs
$0.0014 in vision-model input tokens at $4 per million, **60× the render**. Optimize the MCP tool for tokens and latency, not render CPU (spec §1.5). - Capacity: 2 instances serve ~0.8–1.2 renders/s (§2.4), ~1–1.5M renders/month at a 2× peak-to-average, so 3M needs more instances ($27 each always on). One first read holds the pool ~17 s at p90 and ~2 min at p99 (§2.5).
3.2 Editor export
4.9 vCPU-s per export hosted (est.; uncertainty 3–9), from a content mix of 50% light, 30% photo, 15% photo-heavy, 5% dense effects at the §2.1 size distribution; mean ~18 MP, ~5 MB out. Load: 3.4 busy vCPU on average, 6.2 at the peak hour, 9.3 at the peak minute, so ~12–15 one-vCPU instances with least-busy dispatch (random needs more).
| Option | Fixed | CPU | Egress | Total/month | Per 1k exports |
|---|---|---|---|---|---|
| CF 15 × 1 vCPU / 4 GiB, always on | $405 | $177 | $201 | $789 | $0.44 |
| CF 15 instances, awake 75% | $304 | $177 | $201 | $688 | $0.38 |
| CF 4 × 4 vCPU / 12 GiB, child pool | $321 | $177 | $201 | $705 | $0.39 |
| EKS with KEDA, ~8 vCPU average, on-demand / spot | $339 / $164 | incl. | $814 ($0.09/GB) | $1,153 / $978 | $0.64 / $0.54 |
| CF, free and trial plans only (13%), 3 instances | $81 | $23 | $4 | $113 | $0.48 |
- Egress: ~9 TB/month. Whether bytes leaving a container through its Worker count as container egress is UNVERIFIED; writing the file to R2 and returning a URL avoids the question.
- Bigger instances cost the same per vCPU-hour; they help dispatch (a local least-busy queue) and large-export headroom (12 GiB shared), not price.
- Today: exports cost $0 in compute; the user's browser renders them.
3.3 Other hosts
For MCP, every host with 2 warm workers costs tens of dollars a month: scaling, cold starts and ops effort decide.
| Option | Autoscaling | Cold start | Ops effort |
|---|---|---|---|
| CF Containers, custom 1 vCPU / 4 GiB / 4 GB (§5.2) [CP][CL] | None | Platform 1–3 s [CF] + ours (§2.4) | Low: wrangler + Docker in Workers CI |
CF Containers standard-1 (½ vCPU) or standard-2 (6 GiB) |
None | Same | Same; ½ vCPU is slower (§2.2) |
| EKS, request 1 vCPU / 1 GiB, spot or on-demand [ES][EO] | KEDA | None with 2 warm; a new pod adds ours, plus a node if Karpenter adds one | Medium: 3 repos, platform review |
| AWS Lambda arm64, 2 provisioned [LP] | Yes | No SnapStart for Node [LS]; every scale-out pays ours | Medium: no Node Lambda pipeline; 6 MB response cap [LL] |
| Cloud Run, min 2 [CRP] | Yes | Unpublished; CPU boost [CRC] | Medium: new provider |
Fly performance-1x × 2 [FP] |
Autostop only | About 2 s from stopped [FA] | Medium: new provider |
| Workers / Durable Objects | Doesn't fit (128 MB) |
- EKS cost is what split cost allocation attributes to the namespace, not new cash; two pods may fit spare node capacity. Its egress, $0.09/GB to Cloudflare [EX], is what makes it expensive for exports.
- Not counted: ECR storage (about $0.03) and NAT processing for asset fetches (UNVERIFIED).
4. Kittl's current infrastructure
| Where | What runs there | Evidence |
|---|---|---|
AWS eu-central-1, EKS eks-cluster-production / -staging |
api, editor, website, cms-ui, static-pages, keycloak, live … (mono CI → ECR → development-applications-config → ArgoCD) |
infrastructure/eks-clusters/src/cluster.ts; mono/.github/workflows/reusable-deployment-pipeline.yml |
| Cloudflare Workers | apps/mcp, apps/mockup-renderer, frontend-event-ingest |
mono/.github/workflows/deployment-workers.yml |
| AWS Lambda | One Python Lambda; no Node Lambdas | infrastructure/py/src/lambda.ts |
| GCP | Data and AI only; no hosting | mono/apps/api/src/config/default.config.ts |
| EKS provides | Detail | Evidence |
|---|---|---|
| Nodes | 4 × c7i.2xlarge + 4 × r7i.2xlarge on-demand; Karpenter adds Intel amd64 nodes of 4–16 vCPU, spot or on-demand. No Graviton, Savings Plans or RIs. | infrastructure/eks-clusters/Pulumi.production.yaml, platform-applications/production/karpenter/nodePool.yaml |
| Ingress | One Cilium public gateway behind one NLB, wildcard *.kittl.com; external-dns makes Cloudflare-proxied records. A new hostname adds no LB cost. |
platform-applications/production/cilium/gateway.yaml, platform-applications/base/external-dns/values.yaml |
| Chart | common-chart: KEDA ScaledObject, PDB (minAvailable: 50%), probes, ServiceMonitor; cms-ui runs 2–6 replicas on CPU |
development-applications-config/base/cms-ui/values.yaml |
| Observability | OTLP → OTel collector → VictoriaMetrics and Loki | platform-applications/base/open-telemetry/otel-collector.yaml |
| Cost reporting | Split cost allocation per namespace in CUR 2.0 (UNVERIFIED) |
5. Phase 1: Cloudflare Containers
apps/mcp (Worker) ──HTTPS + user's Keycloak token──▶ kittl-artboard-renderer (Worker)
│ JWT check, user_id, rate limit (to do)
▼
getRandom over 2 container instances (built; least-busy: §7)
Inside each container (built):
supervisor: Node + @hono/node-server (container/server.ts)
POST /render, GET /healthz; 408 past 15 s of upload (direct access only, §7); 413 when the design state body is over 10 MiB;
5 held, counted before the body is read (10 s queue wait at most); 25 s deadline; 30 s startup
│ fork, structured-clone IPC: { body, query } in, { bytes, type, report? } or { status, error } out
▼
render process (container/renderChild.ts): the editor engine on the fake DOM, one job at a time
├─ deadline → SIGKILL, 504, fresh process, queue continues
├─ crash → 500, fresh process, queue continues
└─ memory recycle → fresh process between jobs
| Piece | State |
|---|---|
Container, image, RENDERER binding, getRandom pool |
Built; runs under wrangler dev |
| Environments | staging and production in wrangler.jsonc; one build, with each environment's editor URLs in its vars, passed to the containers as env (spec §1.3); no workers.dev or preview URLs; staging at https://artboard-renderer.stagingdesigner.com (custom domain) |
| Instance | Configured: 1 vCPU / 4 GiB / 4 GB, max_instances 2, in wrangler.jsonc (§5.2) |
| Asset hosts, body limit, recovery | Built, in the container (spec §1.3) |
Worker auth (src/index.ts) |
To do: a Hono app like apps/mockup-renderer/src/index.ts. hono/jwk verifies RS256 against the Keycloak realm keys (iss = KEYCLOAK_REALM_URL), requiring typ: Bearer, exp, user_id and azp = KEYCLOAK_CLIENT_ID (kittl-mcp). Plus a ratelimit binding keyed by user. |
| Shared JWT code | To do: extract apps/mockup-renderer/src/realmKeys.ts and userIdFromAccessToken into a package with a Hono middleware (use the create-package skill) |
| Deploy | Wired in .github/workflows/deployment-workers.yml (WORKERS plus the artboard_renderer input): a merge deploys to staging, production is manual. ubuntu-latest has Docker, so wrangler deploy builds and pushes the image. Staging is public and unauthenticated for team testing; production gets artboard-renderer.kittl.com with the auth PR. |
⚠️ The Dockerfile stays at container/Dockerfile, never apps/artboard-renderer/Dockerfile. Mono's EKS pipeline deploys every affected app that has apps/<name>/Dockerfile (.github/actions/get-affected-projects/action.yml): every merge would push to an ECR repo and sync an ArgoCD app that don't exist, failing the staging deploy.
5.1 How Cloudflare runs it
Worker (src/index.ts) ── getRandom(env.RENDERER, 2) ──▶ Container class = one Durable Object per instance
└─ linux/amd64 image, Node on port 8080
| Fact | Here | Source |
|---|---|---|
| Containers is generally available | [CG] | |
The image must run on linux/amd64 |
container/Dockerfile defaults to it |
[CGS] |
| A container is reached only through its Durable Object; no inbound TCP/UDP from end users | Only the Worker's RENDERER binding reaches it |
[CA] |
getRandom picks one of a fixed N instances per request, regardless of location or load |
N = 2; random choice wastes capacity (§2.4) | [CC][CS] |
| A request starts a stopped container | Cold start on the first request after sleep | [CC] |
sleepAfter stops an instance with no requests (default 10 min) |
1h in src/index.ts |
[CC] |
| Cold starts are often 1–3 s, depending on image size and startup | Plus ours (§2.4) | [CF] |
| Disk is ephemeral: a restart starts from the image | The renderer keeps no state | [CA] |
Stopping sends SIGTERM, then SIGKILL after up to 15 min |
SIGTERM lets in-flight requests finish within 30 s (container/server.ts) |
[CA] |
| Out of memory: OOM error and restart; no swap | §2.7 | [CF] |
| No autoscaling | max_instances is fixed |
[CF] |
| A process that exits on its own returns the instance to stopped | Only the supervisor exits (a startup config error or its own bug); deadlines, crashes and the memory guard replace the render process. Restart on the next request follows from the row above, UNVERIFIED in staging | [CA] |
5.2 Size
Custom 1 vCPU / 4 GiB / 4 GB disk, max_instances 2. Custom instance types are open to all Containers users [CIT] and need at least 1 vCPU and at least 3 GiB per vCPU [CL]. Cost: §3.1.
| Choice | Why |
|---|---|
| 1 vCPU | Renders are CPU-bound and run one at a time (§2.2) |
| 4 GiB | Headroom over the §2.7 worst case (about 1.75 + 1.4 GiB) |
| 4 GB disk | The image is about 285 MB uncompressed (node:22-slim, the 7 MB render process bundle, @napi-rs/canvas); the image limit equals the instance disk [CL] |
max_instances 2 |
Enough for MCP up to |
The Worker upload is about 580 KiB, 390 KiB gzipped; most of it is the docs' two images, inlined as base64.
6. Phase 2: existing EKS
Move when any is true: the pool queues renders (queue wait or 503s); Cloudflare Containers is unreliable or slow in staging; the renderer must sit next to the API (fetch by design id, private assets); the platform team wants one platform. Not for exports alone: egress makes EKS the most expensive option there (§3.2). If Cloudflare ships Containers autoscaling, phase 2 may never be needed. Warm 2048 light text and vector renders take ≤ 0.56 s at 1 vCPU on staging's slower instance (§2.2), under the 1 s that would call for bigger instances.
Target: the phase-1 image on common-chart in eks-cluster-production; KEDA on CPU, min 2, max 6; requests 1 vCPU / 1Gi, limits 1 vCPU / 4Gi (§2.7; a 500m request once bursting proves reliable); httpRoutes for artboard-renderer.kittl.com on the public gateway with cloudflareProxied: true. The JWT middleware moves into the container and rate limiting into a Cloudflare rule. It reuses the API's chart, pipeline, gateway, observability and cost reporting; two warm replicas plus the PDB cover spot interruptions.
infrastructure: a Pulumi project withK8sInit(namespace + ECR repoartboard-renderer),VaultInitif it needs secrets, registered in.github/pulumi-projects.json(infrastructure/docs/ai/RUNBOOKS.md).development-applications-config:base|staging|production/artboard-renderervalues: port,/healthzprobes, resources, KEDA, OTel env likedashboard,httpRoutes. The ArgoCD ApplicationSet picks it up (UNVERIFIED).- mono, merged only after 1–2 exist: move the Dockerfile to
apps/artboard-renderer/Dockerfile, add a.github/build-runners.ymlentry, and akittl-chartalias for PR previews or an exclusion from.github/workflows/ci-preview.yml. - Cut-over: point
KITTL_ARTBOARD_RENDERER_BASE_URLat EKS (or swap DNS), retire the Worker and itsWORKERSentry, read the namespace cost from the CUR after a week.
7. Limitations and follow-ups
The alphaMask.ts fix (spec §6.1) ships to the editor in its own PR, before the app. Auth comes before any production route or the MCP tool (Access row below).
Ranked follow-ups:
| # | Follow-up | Why | Where |
|---|---|---|---|
| 1 | Don't retry a 4xx when exporting | Cheapest win: removes the ~6 s idle, slot held, on every board with a private or missing asset (§2.3) | Image loading in export mode (fetchRetry's retry rule): separate PR |
| 2 | Remove the double load | −35–50% CPU on every render, lower peak memory, half the 4xx retry wait (§2.3) | @repo/rendering PR: spec §6.2 |
| 3 | Load private assets with the caller's credentials, to the allowlisted asset hosts only | The file misses them on any design with user uploads (§2.3) | With the auth PR: the Worker already receives the user's token |
| 4 | Least-busy dispatch and a per-user concurrency cap | Random dispatch ran at ~62% efficiency hosted and sends half the work to the slower instance; 1.14 vs 1.8 renders/s natively (§2.4); one p99 first read holds the pool ~2 min (§2.5) | Worker |
| 5 | Remaining staging measurements | Cloudflare cold start, exports above 2048, why the two instances differ (x-container, x-cpu), recovery (deadline, crash, recycle), OOM behaviour, Cloudflare's container metrics |
Staging (pnpm test:e2e covers the container locally) |
| 6 | Editor export, as its own project | §1.1 | Separate track |
| Area | Built | Remaining |
|---|---|---|
| Deadline | The supervisor SIGKILLs the render process past 25 s, even inside synchronous drawing; the queue continues |
Scale with output pixels for exports (§1.1) |
| Crashes | 500 for that request; a fresh render process takes the queue | |
| Memory | The guard replaces the render process between renders | A heavy render right after the check (§2.7) |
| Overload | 503 with retry-after with 5 requests held, counted before their body is read, or after 10 s in the queue |
Follow-up 4; raise max_instances or move to EKS (§6) |
| Slow uploads | The container answers 408 past 15 s of upload, on direct access (locally, in tests). Through the edge, a steady slow upload is buffered before the container, succeeds and never holds a render slot; an upload stalled for 15 s is reset (ECONNRESET) without a status, likely a platform idle timeout (UNVERIFIED which layer) |
Callers retry a reset like a 503 |
| Missing assets | Still 200, with x-render-report listing each missing asset and why |
Follow-ups 1 and 3; workspace fonts fall back silently (they need the caller's session) |
| Asset hosts | http(s) on the editor's asset origins and images.unsplash.com only; no redirects |
|
| Input size | 413 when the design state body is over 10 MiB, declared or chunked, through the edge too (p99 real state: 460 KB, max 5.25 MB, §2.1); the Worker drops expect (the container's 100 Continue fails in the platform's proxy) |
Caps on output pixels and asset size |
| Environments | Runtime vars per environment, checked at startup; one build |
|
| Access | Staging is public, without auth, for team testing | Keycloak JWT and a per-user rate limit in the Worker, with the JWT check extracted from apps/mockup-renderer into a shared Hono middleware (create-package skill) |
| Deploy | A merge deploys to https://artboard-renderer.stagingdesigner.com | The production route, with auth; follow-up 5 |
| MCP tool | KITTL_ARTBOARD_RENDERER_BASE_URL in apps/mcp, src/tools/<area>/tools/renderArtboard/ registered like registerMockupTools.ts and called with the user's Authorization: Bearer like fetchRemoteMockupRender, returning the image as MCP image content, with bounded retries on 503; behind a flag. Sizes, first read, contact sheet and cache: spec §1.5. Production: a manual Workers deploy, then the flag. |
Accepted for v0:
| Limitation | Why it's acceptable |
|---|---|
| A container failure or OOM loses its in-memory requests | No durable jobs: the Worker answers 503 and the caller retries |
| 2 renderers, no autoscaling | Enough for MCP; overload is explicit (503 with retry-after) |
Dense shadows render slowly, and exportSubState loads the board twice |
The deadline bounds it; follow-up 2 |
| PDF: blurred shadows of raster images are missing (spec §2) | Skia's SVG renderer ignores filters on <image> |
No CMYK, mp4 or mockup boards |
400 or 422 with a reason (spec §2); mockups have apps/mockup-renderer; needed for exports (§1.1) |
8. Open questions
- Why staging's two instances run at two speeds (§2.2); the Cloudflare cold start (§2.4); whether the slow first-seen render is a cold regional asset cache (follow-up 5).
- Which layer resets an upload stalled for 15 s at the edge (§7).
- Whether 4 GiB and the guard's thresholds hold above 2048 px hosted (they hold at 2048, §2.7), and that a container that exited (out of memory) restarts on the next request (§5.1).
- Whether container → Worker bytes count as container egress (§3.2).
- Whether the PNG gap is encode time (§2.6); exports above 8,192 px and SVG/PDF above 2048 px are unmeasured, and p99 export memory is extrapolated.
- How often a design is saved: unknown, so result-cache hit rates are reasoned, not measured.
- Where EKS pods land (spot or on-demand), and the real marginal cost; whether the
kittl-chartalias is still needed; the ArgoCD ApplicationSet; AWS NAT processing cost; the name of the shared JWT package.
Sources
- [L] https://developers.cloudflare.com/workers/platform/limits/
- [DL] https://developers.cloudflare.com/durable-objects/platform/limits/
- [CP] https://developers.cloudflare.com/containers/pricing/
- [CL] https://developers.cloudflare.com/containers/platform-details/limits/
- [CF] https://developers.cloudflare.com/containers/faq/
- [CS] https://developers.cloudflare.com/containers/platform-details/scaling-and-routing/
- [CC] https://developers.cloudflare.com/containers/container-package/
- [CA] https://developers.cloudflare.com/containers/platform-details/architecture/
- [CIT] https://developers.cloudflare.com/changelog/post/2026-01-05-custom-instance-types/
- [CG] https://developers.cloudflare.com/changelog/post/2026-04-13-containers-sandbox-ga/
- [CGS] https://developers.cloudflare.com/containers/get-started/
- [EO] EC2 on-demand, EU (Frankfurt), Linux: https://b0.p.awsstatic.com/pricing/2.0/meteredUnitMaps/ec2/USD/current/ec2-ondemand-without-sec-sel/EU%20(Frankfurt)/Linux/index.json
- [ES] EC2 spot, eu-central-1: https://website.spot.ec2.aws.a2z.com/spot.js (changes continuously)
- [EX] AWS data transfer, EU (Frankfurt): https://b0.p.awsstatic.com/pricing/2.0/meteredUnitMaps/datatransfer/USD/current/datatransfer.json ($0.09/GB, first 10 TB)
- [LP] https://aws.amazon.com/lambda/pricing/
- [LL] https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html
- [LS] https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html
- [CRP] https://cloud.google.com/run/pricing
- [CRC] https://docs.cloud.google.com/run/docs/configuring/services/cpu
- [FP] https://docs.fly.io/about/pricing
- [FA] https://docs.fly.io/launch/autostop-autostart