ShipEasy

How it works

A tour of the moving pieces — two ShipEasy, one shared D1, KV at the edge, and an SDK that never blocks your request.

ShipEasy · Architecture

Fast reads, explicit writes, no surprises in your hot path.

ShipEasy splits the world into two runtimes — a Next.js admin app and an ShipEasy — sharing one D1 database and a handful of config blobs. Reading a flag, an experiment, or a translation is a local memory lookup. Changing one purges a single CDN URL and propagates worldwide in under a second.

Production readyOn this page · 7 min readUpdated · May 3, 2026Works with · ShipEasy · KV · events store · Queues

ShipEasy is built around one rule: the read path is fast and the write path is explicit. Reading a flag, an experiment assignment, or a translation should never block your request. Changing one should be visible globally within a second of the dashboard click.

That single rule decides almost every architectural choice — no Durable Objects, no SSE, no per-request fetches, no TTL-based invalidation. Just config blobs at the edge, polled in the background, and updated on change.

The two-runtime split

Admin app

apps/ui is a Next.js 16 app (App Router, React 19) deployed to a Cloudflare Worker via @opennextjs/cloudflare. It owns:

  • The dashboard UI for gates, configs, experiments, profiles and labels.
  • Server Actions and Route Handlers under /api/* — the same endpoints the CLI calls.
  • Auth.js v5 sessions (stateless JWT, 15-minute expiry).
  • The KV rebuild + CDN purge pipeline. Whenever you change a flag, the admin app rebuilds the affected blob and purges the URL.

Writes never go straight to KV from the dashboard. They go to D1 first (the row of truth), then a rebuild helper in @shipeasy/core/kv reassembles the blob and writes it back, then an explicit purge invalidates the CDN.

Edge worker

packages/worker is a app deployed as a separate Worker. It is read-mostly and stateless. The two endpoint groups that matter:

  • /sdk/* — what your SDK polls. Returns the config blob unchanged. Cached at the edge with a long TTL; the admin's purge step is what makes the cache eventually-consistent.
  • /collect — fire-and-forget event intake. Writes to events store. Returns 202 immediately. Your code path doesn't wait on it.

The same worker also handles the CLI device-auth flow (/auth/device/*) and runs the cron + queue pipeline.

Shared state

Field
Type
Description
D1 (SQLite)required
row store
Source of truth — gates, configs, experiments, profiles, keys, exposures, daily results. Schema in packages/core/src/db/schema.ts. Project-scoped via db/scoped.ts.
KVrequired
read cache
Two blobs per project: :flags (gates + configs) and :experiments (universes + experiments). Plus per-profile chunks for i18n labels.
events storerequired
event store
Append-only telemetry. Written from the worker only, never from the admin app.
Queuesrequired
async pipeline
The cron enqueues one message per project. The consumer runs analysis and writes results back to D1.

The lifecycle of a write

When you flip a flag, six things happen — all within about a second.

You change something in the dashboard or CLI

A flag flipped, a rollout bumped, a translation published. The CLI hits the same Server Action the dashboard does — there is no second API.

The admin app writes the row to D1

D1 is the source of truth. Every other surface (config blob, daily analysis row, dashboard table) is derived from a D1 row.

The config blob is rebuilt

The rebuild helper assembles the full project blob — every gate, every config, every targeting rule — into a single JSON payload. Rebuild is cheap because the project is small; we don't do partial updates.

The blob is written to KV

KV propagation is sub-second between Cloudflare colocations. The blob is small (a few KB for most projects, low MB for large ones).

The CDN URL is purged

Reads are cached at the edge with infinite TTL. The purge invalidates the single URL that points at the project's blob — every other project's cache stays warm.

Your SDK polls and picks up the change

Server SDKs poll on a plan-driven interval (Free 60s, Pro 10s, Enterprise streaming). Browser SDKs refetch on init or on client.refresh(). Total time-to-visible from the dashboard click is < poll interval + ~100ms of CDN propagation.

Why infinite TTL and not, say, 30 seconds?

TTL-based invalidation makes the worst-case latency equal to the TTL. Explicit purge makes the worst-case latency equal to the CDN propagation time, which is sub-second worldwide. The only downside — coordinating the purge — is a problem the admin app already solves on every write.

The lifecycle of a read

The other direction is much shorter — and on purpose.

Your code asks the SDK

flags.gate("new-checkout", {user_id}). Synchronous. No Promise.

The SDK evaluates locally

The full rule set for every gate in your project lives in process memory. Targeting rules and rollout buckets are evaluated against the user object you passed in. Bucketing is deterministic — same user, same answer, every time.

A background poll keeps the bundle fresh

A worker thread (Node) or setInterval (browser) re-fetches the config blob on the plan-driven cadence. If the body is unchanged the SDK does nothing; if it changed, the in-memory rule set swaps atomically.

Exposure events are batched

Each evaluation that resolves to an experiment variant queues a small exposure event. Events are flushed to /collect in batches — navigator.sendBeacon on page hide in the browser, periodic flush + on-process-exit on the server.

There is no per-evaluation network call, no rate limit on gate(), and no async surface to wrap. The cost of flags.gate() is approximately the cost of a hash plus a few comparisons.

Two SDK builds, one package

@shipeasy/sdk ships server and browser builds in the same npm package, picked by your bundler via conditional exports (nodedist/server, browserdist/client). Both share the same evaluation core, but differ in their environment assumptions:

Field
Type
Description
Server buildrequired
@shipeasy/sdk/server
For Node, Workers, Bun, Deno, RSC. Polls in the background. You pass the user object on every call.
Client buildrequired
@shipeasy/sdk/client
For browsers. Manages an anonymous_id cookie. Identifies once, reuses the user. Ships a devtools overlay.

The browser build can also be bootstrapped from the server to avoid the first-paint roundtrip. See SDKs → SSR bootstrap.

Plan-driven knobs

A handful of behaviours are plan-derived rather than per-project. The big one is the SDK poll interval:

PlanServer pollBrowser refreshStreaming
Free60son init
Pro10son init + manual
Enterprise1sstreaming

Bumping a plan propagates through the next KV rebuild — no per-project migration. The constants live in packages/core/src/config/plans.ts.

Identity model

A two-tier identity is enough for almost every use case:

client.identify({
  user_id: "u_4f2a", // your stable user ID, set after login
  anonymous_id: "anon_xyz", // pre-login ID, auto-managed by the browser SDK
  plan: "pro",
  country: "US",
  beta_tester: true,
});

ShipEasy buckets by user_id ?? anonymous_id, so a user gets a stable assignment before and after login. When a user authenticates, call client.alias(anonymous_id, user_id) to stitch their pre-login exposures to their post-login identity.

For B2B, you can bucket by account_id instead, so all teammates see the same variant. See User attributes for the full guide.

What we deliberately don't do

Trade-offs in plain English
  • No Durable Objects. They're great, they're also a per-project moving part we don't need for read-mostly state. - No SSE / WebSockets. Polling at plan interval is good enough for flag changes and removes a class of operational headaches. - No per-request fetch from the SDK. The bundle is in process memory. - No TTL-based KV invalidation. Writes purge the affected URL explicitly. - No vendor lock-in for stats. Experiment results are plain rows in your D1 — exportable, queryable, yours.

Where to next

READY?

Stop reading. Start shipping.

The fastest way to feel ShipEasy is to install it on a real repo. The free tier is generous and the CLI does the boring parts.

Install the CLI
$npm install -g @shipeasy/cli
Then log in
$shipeasy login
Detects framework · stateless evaluation · sub-second propagation
Was this page helpful?✎ Edit on GitHub

On this page