Skip to content

ADR-0003: Worker Splitting & Client-Side Session KV Elimination

ADR-0003: Worker Splitting & Client-Side Session KV Elimination

Section titled “ADR-0003: Worker Splitting & Client-Side Session KV Elimination”

Status: 🟢 Approved & Adopted
Date: 2026-09-29
Deciders: SiteSwarm Architecture Council, Platform Engineering
Related Epics & Issues: Epic #66 (Standardization & Fleet Wave 1), Issue #65, Epic #38 (Content Invalidation & Freshness), Issue #57 (Ingress & Preview Routing), Issue #28 (Client CMS)
Related Architecture Specifications: HIGH_LEVEL_DESIGN.md, docs/INGRESS_AND_PREVIEW_ROUTING.md, docs/CLIENT_CMS.md, docs/DATA_ISOLATION_AND_STORAGE.md


SiteSwarm powers dozens of independent client web applications for local small businesses (bakeries, boutique agencies, medical clinics, auto repair shops) from a unified monorepo. Small business client applications are fundamentally content-driven, static-first websites. Anonymous public visitors reading menus, inspecting portfolios, or checking business hours do not maintain stateful server sessions; their interactions are globally readable, pre-renderable, and cache-friendly.

However, our empirical preview deployment pipelines and CMS integration spikes uncovered two acute architectural tensions:

1.1 The Astro Session KV Auto-Provisioning Problem

Section titled “1.1 The Astro Session KV Auto-Provisioning Problem”

When @astrojs/cloudflare is configured in Server-Side Rendering (SSR) mode (output: "server"), Astro’s session subsystem historically defaults to Cloudflare KV for server session persistence, automatically injecting a default KV namespace binding into the Cloudflare deployment manifest:

{
"kv_namespaces": [
{
"binding": "SESSION"
}
]
}

During ephemeral pull request preview deployments (e.g. green-leaf-bakery-preview-pr-55), Wrangler interprets this declared binding as a deployment requirement. If an explicit KV namespace ID is not supplied in the environment configuration, Wrangler dynamically provisions an ephemeral KV namespace in the Cloudflare account.

This introduces severe operational friction:

  1. Quota Exhaustion: Cloudflare accounts have hard limits on the number of active KV namespaces (e.g. 100 on standard plans). Ephemeral preview namespaces generated per PR across multiple apps risk exhausting account quotas.
  2. Teardown Overhead: Destroying preview workers requires orchestrating cascading KV namespace deletions. Any orphaned KV namespace remains as persistent account-level technical debt.
  3. Architectural Mismatch: Public visitors reading a bakery menu or agency case study have zero need for visitor session KV. Binding session storage to public marketing frontends is 100% dead weight.

The Resolution: Non-KV Session Driver Configuration

Section titled “The Resolution: Non-KV Session Driver Configuration”

Early prototypes attempted to bypass this issue by forcing public client applications into pure build-time static site generation (output: "static"). However, this created a severe architectural contradiction: if public frontends were purely build-time static, updating CMS content (e.g. an 8:15 AM sold-out pastry or emergency weather closure) would require triggering a CI/CD build and redeploying the worker, violating the sub-2-second phone-to-edge freshness invariant.

The authoritative resolution is configuring Astro with a non-KV session driver (such as session: { driver: "memory" } in astro.config.mjs) or omitting KV session configuration entirely. This completely eliminates @astrojs/cloudflare’s automatic SESSION KV namespace injection while allowing CMS-enabled client frontends (Worker A) to execute in stateless Edge SSR mode (output: "server"). Public frontends re-render fresh HTML on edge cache misses at runtime without requiring CI/CD rebuilds, while maintaining zero KV bindings. Non-CMS static sites (marketing, docs, me-portfolio) remain permitted to use output: "static".

1.2 The Monolithic Worker Blast Radius Problem

Section titled “1.2 The Monolithic Worker Blast Radius Problem”

In initial prototypes, EmDash CMS (emdash + @emdash-cms/cloudflare) was bundled directly inside the client’s public Astro application. While convenient for single-site prototypes, hosting the CMS within the public web worker causes severe side-effects:

  1. Bundle Bloat & Cold Start Penalties: The public edge worker bundles SSR middleware, Kysely SQL query builders, SQLite D1 connection handlers, and administrative UI assets. This inflates the worker bundle from < 50 KB to > 1.5 MB, increasing edge cold starts from sub-15ms to 150ms–250ms.
  2. Direct D1 Binding Exposure: The public frontend worker holds a direct, privileged binding to the client’s Cloudflare D1 database. If a viral traffic spike hits the public site or a denial-of-service attack targets public endpoints, uncached SSR routes can saturate D1 SQLite connection queues.
  3. Shared Blast Radius: A runtime exception, unhandled error, or heavy database migration inside the CMS administrative engine can crash the V8 isolate, taking down public visitor availability for the entire client website.
  4. Security Perimeter Impossibility: We cannot cleanly apply Cloudflare Access / Zero Trust policies to /admin and /_emdash/* without risking authentication challenges leaking into public visitor traffic or complex edge WAF path exclusions.

We require an architectural pattern that guarantees 100% zero-KV, sub-20ms edge delivery for public visitors backed by heavy Anycast edge caching and instant Cache-Tag purge invalidation, while providing an isolated, secure, dynamic edge runtime for client CMS operations (Worker B).


  1. Zero-KV Public Delivery: Public marketing frontends must build, deploy, and execute with zero KV namespace bindings, zero auto-provisioned preview KV namespaces, and zero client JS runtime overhead. This is achieved via session: { driver: "memory" } without forcing build-time SSG.
  2. Sub-20ms Global Edge TTFB via Heavy Caching: All public pages must be delivered from Cloudflare Anycast edge cache (s-maxage, stale-while-revalidate, Cache-Tag). On edge cache misses (after a tag purge or cache expiration), Worker A executes stateless Edge SSR to dynamically render fresh HTML and re-cache it, achieving instant freshness without CI/CD rebuilds.
  3. Zero CI/CD Rebuilds on Content Updates: Business owner updates in the CMS must reflect live globally in under 2 seconds via instant Cache-Tag purge (@siteswarm/service-cache-purge) rather than triggering multi-minute git commits and CI build pipelines.
  4. Strict Blast Radius Containment: Administrative operations, heavy CMS dependencies, schema migrations, and admin sessions must be physically isolated in an independent compute isolate (Worker B). Admin failures must never degrade public site availability.
  5. Zero Trust Perimeter Protection: CMS routes (/admin, /_emdash/*) must be gated behind Cloudflare Zero Trust / Cloudflare Access without requiring non-technical business owners to navigate separate confusing hostnames, and without affecting public visitors.
  6. Single-Domain User Experience: Business owners expect to manage their website at https://client.com/admin (or /_emdash/admin) without forced cross-origin redirects to third-party subdomains, while maintaining zero latency overhead.
  7. Clean Ephemeral Lifecycle: PR preview environments must deploy in seconds with zero stateful resource provisioning (no ephemeral KV) and clean up instantaneously on PR merge.

3. The Decision: Worker Splitting Architecture

Section titled “3. The Decision: Worker Splitting Architecture”

We formally adopt a Worker Splitting Topology that decouples the public Edge SSR frontend (with heavy Anycast caching) from the dynamic CMS and admin runtime:

flowchart TD
subgraph ClientBrowsers["Client & Visitor Traffic"]
Visitor["Public Visitor Browser\n(Anonymous Customer)"]
AdminUser["Business Owner / Admin Mobile\n(Authenticated Staff)"]
end
subgraph EdgeIngress["Cloudflare Edge Anycast Ingress (Cloudflare for SaaS)"]
CF_WAF["Cloudflare WAF & Security Layer\n(DDoS Shield, Turnstile, Rate Limiting)"]
CF_Cache["Cloudflare Anycast Edge Cache\n(s-maxage=86400, SWR=60, Cache-Tag)"]
CF_Access["Cloudflare Access / Zero Trust Perimeter\n(Path Rule: /admin* & /_emdash/*)"]
end
subgraph WorkerFleet["SiteSwarm Decoupled Worker Fleet"]
direction TB
subgraph WorkerA["Worker A: Public Frontend Worker (@siteswarm/app-*)"]
direction TB
EdgeSSR["Stateless Edge SSR (output: 'server')\nHeavy Caching | Sub-20ms TTFB via Cache\nZero Session KV (driver: 'memory')"]
Router["Zero-Latency Path Router"]
PublicPages["/, /menu, /about, /contact\n(Full HTML, 0 KB Client JS, Zero Session KV)"]
end
subgraph WorkerB["Worker B: Isolated CMS & Admin Worker (@siteswarm/service-cms-*)"]
direction TB
EmDash["EmDash CMS Engine (Astro/React Studio)"]
AdminAuth["Admin Auth & Session Handler (Encrypted Cookies)"]
MCP["Model Context Protocol Agent API (/_emdash/api/mcp)"]
D1Binding["Cloudflare D1 Binding (Atomic SQLite)"]
R2Binding["Cloudflare R2 Binding (Media Storage)"]
end
subgraph PlatformPurge["Platform Service (@siteswarm/service-cache-purge)"]
PurgeAPI["Cloudflare Cache Purge API (<150ms Global Purge)"]
end
end
Visitor -->|GET /menu| CF_WAF
CF_WAF --> CF_Cache
CF_Cache -->|"Cache HIT (>99%)\n<20ms TTFB"| Visitor
CF_Cache -->|"Cache MISS\n(Post-Purge or Expiration)"| WorkerA
WorkerA --> PublicPages
PublicPages -->|"Dynamic Edge SSR\n(Fetch CMS state via Service Binding)"| WorkerB
WorkerA -->|"Re-cache with Cache-Tag"| CF_Cache
AdminUser -->|GET /admin| CF_WAF
CF_WAF --> CF_Access
CF_Access -->|Authorized Staff| WorkerA
WorkerA -->|Path Match: /admin* or /_emdash/*\nService Binding: env.CMS_SERVICE| WorkerB
WorkerB --> EmDash
EmDash --> D1Binding
EmDash --> R2Binding
EmDash -->|"Content Publish -> Trigger Purge"| PlatformPurge
PlatformPurge -.->|"Purge by Cache-Tag (<150ms)"| CF_Cache

Worker A: Public Client Frontend (apps/<client>)

Section titled “Worker A: Public Client Frontend (apps/<client>)”
  • Execution Mode: Stateless Edge SSR (output: "server" for CMS-enabled client sites, eliminating the need for CI builds on content updates; output: "static" is permitted for non-CMS sites such as marketing, docs, and me-portfolio).
  • Heavy Anycast Edge Caching: Responses attach edge cache control headers with tenant surrogate keys:
    Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=60
    Cache-Tag: tenant:<client-slug>
  • Zero-Session-KV Guarantee: Configured with session: { driver: "memory" } in astro.config.mjs, completely suppressing the @astrojs/cloudflare SESSION KV namespace injection. Zero KV namespaces are created or bound in preview or production.
  • Shielded Database Boundary: Zero direct D1 database bindings on public render routes. CMS state is fetched over internal Service Bindings (env.CMS_SERVICE) and protected by an in-memory micro-cache with single-flight promise coalescing.
  • Client JS Overhead: 0 KB client JS for standard content pages.
  • Performance: Sub-20ms edge TTFB delivered directly from Cloudflare’s Anycast edge cache on cache hits. On cache misses, Worker A executes Edge SSR in < 50ms and re-populates the edge cache.
  • Service Binding: Exposes an internal Service Binding client to Worker B:
    // wrangler.jsonc (Worker A)
    {
    "name": "green-leaf-bakery",
    "compatibility_date": "2026-09-29",
    "compatibility_flags": ["nodejs_compat"],
    "services": [
    {
    "binding": "CMS_SERVICE",
    "service": "green-leaf-bakery-cms"
    }
    ]
    }
  • Astro Edge Configuration:
    // astro.config.mjs (Worker A - CMS-enabled client)
    import { defineConfig } from "astro/config";
    import cloudflare from "@astrojs/cloudflare";
    export default defineConfig({
    output: "server", // Stateless Edge SSR for dynamic revalidation without CI builds
    adapter: cloudflare({
    imageService: "passthrough",
    }),
    session: {
    driver: "memory", // Eliminates @astrojs/cloudflare default SESSION KV auto-provisioning
    },
    });
  • Edge Ingress Routing Logic:
    // src/middleware.ts or edge fetch handler in Worker A
    export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    // Route admin & CMS paths directly to Worker B via 0ms Service Binding
    if (url.pathname.startsWith('/admin') || url.pathname.startsWith('/_emdash')) {
    return env.CMS_SERVICE.fetch(request.clone());
    }
    // Public visitor requests are served from Edge SSR with Anycast edge caching
    return env.ASSETS ? env.ASSETS.fetch(request) : handlePublicRequest(request, env, ctx);
    }
    };

Worker B: Isolated CMS & Admin Worker (services/cms-<client>)

Section titled “Worker B: Isolated CMS & Admin Worker (services/cms-<client>)”
  • Execution Mode: Dynamic SSR Worker isolate dedicated exclusively to administrative workflows.
  • Hosted Applications: EmDash CMS Studio, /admin, /_emdash/*, admin API endpoints, and Model Context Protocol (MCP) agent endpoints (/_emdash/api/mcp).
  • Bindings: Holds the privileged Cloudflare D1 database binding (binding: "DB") and Cloudflare R2 media bucket binding (binding: "MEDIA").
  • Session Management: Session state is strictly confined to Worker B. Worker B uses encrypted HTTP-only session cookies (or an isolated, non-ephemeral administrative KV namespace if needed) without leaking any session dependency into Worker A.
  • Blast Radius Isolation: Memory consumption, CPU spikes from image optimization, schema migrations, and admin errors are physically confined to Worker B. If Worker B fails or is taken offline for maintenance, Worker A continues serving public visitors at 100% availability.

4. Ingress & Routing Evaluation: Subdomains vs. Service Bindings

Section titled “4. Ingress & Routing Evaluation: Subdomains vs. Service Bindings”

We evaluated two candidate ingress architectures for routing traffic between the public frontend and the isolated CMS worker:

Architectural Dimension Option A: Subdomain Routing (admin.client.com) Option B: Cloudflare Service Bindings (Selected Canonical)
Ingress Topology client.com -> Worker A
admin.client.com -> Worker B
client.com/* -> Worker A
Worker A proxies /admin* to Worker B via Service Binding
Network Latency Direct public edge DNS resolution to Worker B 0ms in-process V8 isolate hop via Cloudflare Service Binding
Cloudflare for SaaS Cost Requires 2 Custom Hostnames per client (www + admin) Requires only 1 Custom Hostname per client (www.client.com)
DNS Configuration Friction Client must configure additional DNS CNAME for admin subdomain Zero DNS friction: Single CNAME handles public site and /admin
Zero Trust / Access Perimeter Cloudflare Access application scoped to subdomain admin.client.com Cloudflare Access path-based rule scoped to /admin* and /_emdash/*
Cookie & CORS Isolation Cross-subdomain cookie scope or CORS headers required Same-origin (client.com/admin): Zero CORS preflight, strict same-site cookies
Mobile Business Owner UX Client must remember separate URL (admin.bakery.com) Client opens familiar bookmark: bakery.com/admin
Architectural Verdict ⚠️ Supported for Enterprise Tier 🟢 Adopted as Canonical Platform Standard

4.1 Why Option B (Service Bindings) is the Canonical Standard

Section titled “4.1 Why Option B (Service Bindings) is the Canonical Standard”
  1. SaaS Custom Hostname Quota Efficiency: Cloudflare for SaaS plans include a quota of custom hostnames (100 included on Workers Paid). Subdomain routing burns 2 custom hostnames per client, halving fleet capacity. Service Bindings route everything through the primary custom hostname (www.client.com), doubling our client density per Cloudflare account.
  2. Zero In-Network Overhead: Cloudflare Service Bindings do not perform external TCP/TLS network calls. Invoking env.CMS_SERVICE.fetch(request) executes an in-memory V8 isolate dispatch within the same Cloudflare edge data center at 0ms network latency.
  3. Frictionless Mobile Onboarding: Business owners simply append /admin to their familiar website domain on their mobile phones.

4.2 Support for Option A (Subdomain Routing)

Section titled “4.2 Support for Option A (Subdomain Routing)”

For large enterprise clients or organizations requiring strict subdomain DNS delegation, Option A is fully supported as an alternative configuration by routing admin.client.com directly to Worker B in Cloudflare for SaaS.


Worker B is shielded from unauthorized access and vulnerability scans using a defense-in-depth security perimeter:

sequenceDiagram
autonumber
actor Attacker as Internet Bot / Attacker
actor Owner as Authenticated Business Owner
participant Edge as Cloudflare Anycast Edge (SaaS)
participant Access as Cloudflare Zero Trust (Access)
participant WorkerA as Worker A (Public Frontend)
participant WorkerB as Worker B (Isolated CMS Worker)
participant D1 as Cloudflare D1 Database
Attacker->>Edge: GET /admin (Scan / Exploit attempt)
Edge->>Access: Evaluate Path: /admin*
Access-->>Attacker: 403 Forbidden / Challenge (Blocked at Edge)
Owner->>Edge: GET /admin (Mobile Safari)
Edge->>Access: Evaluate Path: /admin*
Access->>Owner: Magic Link / Passkey Auth Challenge
Owner-->>Access: Complete Verification
Access->>Edge: Inject Cf-Access-Jwt-Assertion Header
Edge->>WorkerA: Dispatch request
WorkerA->>WorkerB: env.CMS_SERVICE.fetch() (Zero Latency)
WorkerB->>WorkerB: Verify Cf-Access-Jwt-Assertion & Role
WorkerB->>D1: Fetch CMS Content
D1-->>WorkerB: Return Data
WorkerB-->>WorkerA: Admin Studio HTML
WorkerA-->>Owner: 200 OK (EmDash Studio Loaded)
  1. Cloudflare Access (Zero Trust): An Access application rule gates paths matching client.com/admin* and client.com/_emdash/*. Unauthenticated requests are challenged or blocked at Cloudflare’s edge before any compute code in Worker B executes.
  2. Header Assertion Validation: Worker B cryptographically verifies the Cf-Access-Jwt-Assertion header to ensure requests cannot bypass Cloudflare Access.
  3. Physical Isolation from Public Routes: Public visitors browsing /, /menu, or /contact never trigger Cloudflare Access policies, ensuring completely frictionless, public browsing.
  4. Database Credential Boundary: Worker A has zero bindings to D1 or R2. Even if a hypothetical vulnerability existed in Worker A’s static asset router, it has no cryptographic or capability access to the underlying SQLite database.

6. Content Invalidation Bridge (Epic #38 Alignment)

Section titled “6. Content Invalidation Bridge (Epic #38 Alignment)”

6. Content Invalidation Bridge (Epic #38 Alignment)

Section titled “6. Content Invalidation Bridge (Epic #38 Alignment)”

When a business owner edits content (e.g. updating operating hours or posting an emergency banner) via Worker B, the updated content must propagate globally to Worker A’s edge delivery tier in under 2 seconds without triggering CI/CD rebuilds or introducing database contention.

This aligns directly with Epic #38: Content Invalidation, Edge Caching, and Data Freshness Model.

sequenceDiagram
autonumber
actor Owner as Business Owner (Mobile Phone)
participant WorkerB as Worker B (CMS Studio)
participant D1 as Cloudflare D1
participant Bridge as Platform Purge Service (@siteswarm/service-cache-purge)
participant Cache as Cloudflare Anycast Edge Cache
actor Visitor as Global Visitor
participant WorkerA as Worker A (Public Frontend - Edge SSR)
Owner->>WorkerB: Save Update: "Closed for Snow Day" (POST /_emdash/api/content)
WorkerB->>D1: Atomic SQL Write (UPDATE hours_and_announcements)
D1-->>WorkerB: Commit Confirmed
WorkerB->>Bridge: Trigger Invalidation Event (env.CACHE_PURGE_SERVICE)
Note over Bridge: Tags: ["tenant:green-leaf-bakery"]
Bridge->>Cache: Cloudflare Cache-Tag Purge (Purge by Tag API)
Cache-->>Bridge: Cache Invalidated Globally (< 150ms)
WorkerB-->>Owner: 200 OK ("Published in 1.2s")
Visitor->>Cache: GET / (Visitor opens website)
Cache->>WorkerA: Cache Miss -> Trigger Edge SSR Render
WorkerA->>WorkerB: Fetch Fresh CMS State via Service Binding (env.CMS_SERVICE)
WorkerB-->>WorkerA: Fresh Content JSON
WorkerA-->>Cache: Fresh HTML + Cache-Tag: ["tenant:green-leaf-bakery"]
Cache-->>Visitor: Sub-20ms Fresh Page Delivered (Zero CI Build Required)
  1. Tagged Content Caching: When Worker A executes Edge SSR, it attaches Cloudflare Cache-Tag headers and heavy cache controls:
    Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=60
    Cache-Tag: tenant:green-leaf-bakery
  2. Targeted Tag Purging: Upon successful D1 write, Worker B invokes the centralized Platform Purge Service (@siteswarm/service-cache-purge) via zero-latency Cloudflare Service Binding (env.CACHE_PURGE_SERVICE), targeting the tenant entity tag (e.g. tenant:green-leaf-bakery).
  3. Global Edge Purge in < 150ms: Cloudflare purges the cached assets from all 330+ global edge locations in under 150ms.
  4. Dynamic Edge SSR on Cache Miss (No CI Builds): On the next visitor request following a purge, Cloudflare Anycast encounters an edge cache miss and dispatches to Worker A. Worker A executes stateless Edge SSR, fetches fresh CMS state via env.CMS_SERVICE, renders complete accessible HTML with 0 KB client JS, and re-populates the edge cache. Subsequent visitors hit warm edge cache at sub-20ms TTFB.
  5. D1 Read Shielding: Public visitors never hit D1 directly. Worker A queries Worker B over internal Service Bindings, protected by an in-memory micro-cache (10s TTL) and single-flight promise coalescing. Under viral traffic, 99.9% of requests are absorbed by edge cache, completely shielding D1 from cache stampedes.

  • Zero CI/CD Rebuild Latency for Content: Content mutations reflect globally in under 2 seconds. Small business owners make phone updates without waiting 2-5 minutes for git commits, GitHub Actions runs, and full site redeployments.
  • Zero Ephemeral KV Overhead: Worker A configures session: { driver: "memory" }, completely eliminating the @astrojs/cloudflare default SESSION KV namespace injection. Preview environments deploy cleanly with zero KV quotas consumed.
  • Extreme Frontend Performance: Over 99% of requests serve directly from Cloudflare Anycast edge cache with sub-20ms TTFB and 0 KB client JS.
  • Enhanced Security & Blast Radius Containment: Administrative endpoints and D1 database credentials are physically isolated in Worker B and protected by Cloudflare Access.
  • Unified Domain UX: Business owners manage their site at client.com/admin via 0ms Service Bindings with zero CORS friction.
  • Two Worker Configurations: Each client application maintains two worker definitions (apps/<client> and services/cms-<client>), slightly increasing repository configuration surface.
  • Service Binding Dependency: Local development requires Wrangler multi-worker configuration (wrangler dev with service bindings) or mock fallback.

8. Implementation Roadmap & Migration Steps

Section titled “8. Implementation Roadmap & Migration Steps”
  1. Step 1: Configure Non-KV Session Driver in Frontend Configurations:
    • Configure session: { driver: "memory" } in apps/<client>/astro.config.mjs to eliminate the default SESSION KV binding.
    • For CMS-enabled client applications, configure output: "server" to enable stateless Edge SSR revalidation on edge cache misses. For non-CMS sites (marketing, docs, me-portfolio), preserve output: "static".
    • Verify wrangler.jsonc has no kv_namespaces declared for the frontend.
  2. Step 2: Isolate CMS Engine into Dedicated Worker:
    • Move EmDash CMS integration into @siteswarm/service-cms or per-client CMS worker definitions.
    • Bind D1 and R2 exclusively to Worker B.
  3. Step 3: Establish Service Binding in Frontend Worker:
    • Declare services: [{ binding: "CMS_SERVICE", service: "<client>-cms" }] in Worker A.
    • Route /admin* and /_emdash/* through env.CMS_SERVICE.
  4. Step 4: Configure Cloudflare Access Zero Trust Rules:
    • Apply Zero Trust perimeter to /admin* paths on custom hostnames.
  5. Step 5: Wire Centralized Cache Purge Service:
    • Connect Worker B CMS publish hooks to @siteswarm/service-cache-purge via env.CACHE_PURGE_SERVICE to purge tenant:<client-slug> tags on publish.