Delivery guarantees
Two delivery classes declared per event — best-effort for the noise, durable at-least-once for business facts — plus a trusted source rule for anything with monetary consequence.
Reliability in Dotyc is part of the product, not a documented limitation. It is what makes the core pitch tenable: if feature_used feeds your product analytics and your billing counter, "a lost event is acceptable" cannot be one blanket answer. So the answer is per event.
Two classes of traffic, declared on the event
defineEvent('feature_used', {
tags: ['feature'],
delivery: 'durable', // 'best-effort' (default) | 'durable'
properties: ...,
})best-effort(default) — logs, marketing, traces. The unchanged pipeline: fire-and-forget, isolated fan-out. Losing one is acceptable, and that contract buys you zero overhead: a catalog with no durable events pays nothing for this feature existing.durable— business facts: usage, billing, quotas. At-least-once delivery: persisted before dispatch, acknowledged to the transport, retried per consumer until success.
Heavy processing applies only to the events that deserve it. You do not pay durability costs for a log line.
Every event carries an eventId
Every event — both classes — gets a stable ULID generated at emission:
- Consumer idempotency. At-least-once means the same event can arrive twice; consumers dedupe on
eventId. - Replay protection. The ingest dedupes on a TTL window.
- Temporal ordering. ULIDs sort by time.
The trusted source: source: 'trusted'
defineEvent('subscription_upgraded', {
delivery: 'durable',
source: 'trusted', // 'anonymous' (default) | 'trusted'
})A source: 'trusted' event can only be emitted via direct(dispatcher) — in-process, on the server. Two enforcement layers:
- It is excluded from the client emitter's
track()type: the client cannot even name it. The prohibition is a compile error, not a runtime error. - If one somehow arrives over the HTTP transport, it is rejected at ingest.
There is no client↔server healthcheck pattern in Dotyc, and that is deliberate. Instead of verifying a client's claim after the fact, a critical fact is emitted where it happens: the API route that performs the action emits the event. The server has nothing to check — it is the emitter. Transport reliability is handled by acks, not by pinging the client.
delivery: 'durable', source: 'trusted'.The durable pipeline
persist (outbox via store) → ack transport → fan-out → mark done per consumer
↓ failure
retry with backoff (from the store)
↓ exhausted
dead-letter status + onErrorA durable event is persisted to an outbox before anything else, then acknowledged to the emitter. Fan-out proceeds as usual, but each matching consumer is tracked individually: a consumer that fails is retried with backoff from the store; when retries are exhausted, the event is marked dead-letter for that consumer and surfaced via onError.
The store: a new brick family
The outbox needs persistence, so durability introduces Dotyc's fourth family of pluggable bricks — after adapters, identity providers, and transports — the store:
const dispatcher = createDispatcher({
catalog,
wiring,
store: redisStore(env.URL), // memoryStore() in dev; official Redis / Postgres / KV drivers
})The same store also backs the identify dedupe cache and replay dedupe — which is what solves serverless: shared state across invocations instead of per-instance memory. memoryStore() exists for dev; in production, state never lives in instance memory.
Acks and emitter feedback
The ingest response reports the fate of each event: accepted | rejected | legacy. The emitter reacts per class:
- Durable: kept in a queue until acked; retried otherwise.
- Best-effort: fire-and-forget, unchanged (
sendBeaconon unload). - Dev: rejections are surfaced loudly in the console — no more debugging blind.
Serverless
Vercel/Lambda are an assumed target, not an afterthought:
- State (outbox, dedupe) lives in the pluggable store, never in instance memory in prod.
toHandler(dispatcher, { waitUntil })lets fan-out finish after the response is sent, without being killed at instance freeze.- Buffering adapters get their
flush()called at the end of the invocation.
QoS: the important never travels with the noise
A single pipe for traffic of unequal importance risks head-of-line blocking (best-effort volume delaying business facts) and a shared blast radius. Dotyc partitions at every layer:
- Already partitioned:
trustedevents go throughdirect(), in-process — the most critical facts never take the public HTTP route at all. - Two lanes in the emitter transport: durable events are flushed immediately in their own envelopes; best-effort events are batched lazily. An envelope is never mixed.
- Priority and load shedding at ingest: under pressure, durables are processed first; best-effort events are shed — that is their contract — rather than degrading everyone.
- Double mount (a documented pattern, not an API): the same dispatcher mounted on two routes — say
/ingestand/ingest/critical— with different rate limits, scaling, or WAF rules. Free, sincetoHandleris just a function.
Draft: still being decided
direct() durable events are persisted before dispatch too (probably yes, to survive a crash between emission and dispatch).