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.
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.
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 — Next.js on Workers→
Dashboard, REST API, Server Actions, Auth.js sessions, and the CLI's programmatic surface. This is where humans (and the CLI) make changes.
Edge worker — →
Serves /sdk/flags, /sdk/experiments, /sdk/labels, ingests
events at /collect, and runs the cron analysis pipeline.
Shared state — D1 + KV + AE→
D1 is the row store. KV is the read cache. events store is the event store. All three are scoped per project.
Analysis — Cron + Queues→
A scheduled trigger enqueues one job per project. The consumer runs the t-test and writes results back to D1.
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
packages/core/src/db/schema.ts. Project-scoped via db/scoped.ts.:flags (gates + configs) and :experiments (universes + experiments). Plus per-profile chunks for i18n labels.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.
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 (node → dist/server, browser → dist/client). Both share the same evaluation core, but differ in their environment assumptions:
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:
| Plan | Server poll | Browser refresh | Streaming |
|---|---|---|---|
| Free | 60s | on init | – |
| Pro | 10s | on init + manual | – |
| Enterprise | 1s | streaming | ✓ |
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
- 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
Install→
Add the packages to your project and your machine.
Authenticate→
shipeasy login for humans, SHIPEASY_API_TOKEN for CI.
Ship your first flag→
The shortest path from install to a flag in production.
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.