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

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

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

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)

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

2.7 Memory

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

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

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)

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 1M renders/month (0.8–1.2 renders/s, §2.4); exports need ~12–15 (§3.2). Raise it when MCP traffic queues: 8 in flight already gets 503s (§2.4)

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.

  1. infrastructure: a Pulumi project with K8sInit (namespace + ECR repo artboard-renderer), VaultInit if it needs secrets, registered in .github/pulumi-projects.json (infrastructure/docs/ai/RUNBOOKS.md).
  2. development-applications-config: base|staging|production/artboard-renderer values: port, /healthz probes, resources, KEDA, OTel env like dashboard, httpRoutes. The ArgoCD ApplicationSet picks it up (UNVERIFIED).
  3. mono, merged only after 1–2 exist: move the Dockerfile to apps/artboard-renderer/Dockerfile, add a .github/build-runners.yml entry, and a kittl-chart alias for PR previews or an exclusion from .github/workflows/ci-preview.yml.
  4. Cut-over: point KITTL_ARTBOARD_RENDERER_BASE_URL at EKS (or swap DNS), retire the Worker and its WORKERS entry, 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

Sources