Shipeasy

How it works

A tour of the moving pieces — two runtimes, one shared database, config blobs 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 edge worker — sharing one 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 · June 18, 2026Works with · Edge worker · 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 per-request fetches, no TTL-based invalidation, no streaming sockets. Just config blobs at the edge, polled in the background, and purged on change.

The two-runtime split

Admin app

The admin app is a Next.js app that owns:

  • The dashboard UI for feature flags, configs, experiments, profiles and labels.
  • Server Actions and Route Handlers — the same endpoints the CLI calls.
  • Stateless JWT sessions (short expiry, no session table).
  • 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 the database first (the row of truth), then a rebuild helper reassembles the blob and writes it back, then an explicit purge invalidates the CDN.

Edge worker

The edge worker is a separate, read-mostly, stateless service. Two endpoint groups 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. Returns 202 immediately. Your code path doesn't wait on it.

The same worker also handles the CLI device-auth flow and runs the cron + queue analysis pipeline.

Shared state

Prop

Type

The lifecycle of a write

When you flip a flag, six things happen — all within about a second. The write path fans out from the admin app; the read path and event ingestion live entirely on the edge worker; analysis closes the loop back to the database overnight.

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 the database

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

The config blob is rebuilt

The rebuild helper assembles the full project blob — every feature flag, 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 edge locations. 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. Browser SDKs poll on the same cadence in the background, and re-evaluate on identify(...). 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

new Client(user).getFlag("new-checkout"). Synchronous. No Promise.

The SDK evaluates locally

The full rule set for every feature flag 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 — 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 getFlag(), and no async surface to wrap. The cost of an evaluation 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:

Prop

Type

For the native server SDKs (Go, Python, Ruby, Java, Kotlin, PHP, Swift) the evaluation model is identical — same blob, same deterministic bucketing. See the SDKs reference.

Plan-driven knobs

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

PlanPoll intervalNotes
Free300sOne CDN fetch per project every 5 min.
Team60sSame path, tighter cadence.
EnterprisecustomTalk to us — sub-second tier.

Bumping a plan propagates through the next KV rebuild — no per-project migration. See Plan limits & quotas for the full matrix.

Identity model

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

import { configure, Client } from "@shipeasy/sdk/client";

configure({
  clientKey: process.env.NEXT_PUBLIC_SHIPEASY_CLIENT_KEY ?? "",
  attributes: (u) => ({
    user_id: u.id, // your stable user ID, set after login
    plan: u.plan,
    country: u.country,
    beta_tester: u.betaTester,
  }),
});

// Bind a client to the current user; getters take no user argument.
const flags = new Client(currentUser);

Shipeasy buckets by user_id ?? anonymous_id, so a user gets a stable assignment before and after login. The anonymous_id is auto-managed by the browser SDK (first-party cookie). When identify(...) runs after anonymous activity, the SDK emits an internal alias record so the daily analysis stitches the pre-login exposures to the post-login user_id — no explicit alias call required.

For B2B, you can bucket by company_id instead, so all teammates see the same variant. See Identity & bucketing for the full guide.

What we deliberately don't do

Trade-offs in plain English
  • 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 streaming sockets. Polling at plan interval is good enough for flag changes and removes a class of operational headaches. - No vendor lock-in for stats. Experiment results are plain rows in your database — exportable, queryable, yours.

Where to next

Was this page helpful?
✎ Edit this page

On this page