Caching

Last updated about 2 months ago

j17 caches aggregate state in the background, giving you fast reads and helping you stay within your tier's usage limits. It is on by default for every aggregate type.

The trade-off

Without caching, every GET /{type}/{id} replays all events to compute the current state. This is accurate but uses resources.

With caching: - Faster responses: Reads return instantly from pre-computed state - Lower resource usage: Helps you stay within tier limits - Eventually consistent: There's a brief window (typically under 1 second, worst case ~10 seconds) where reads might be slightly stale after a write

Cached reads are always current: the platform checks the cache entry against the stream on every read and folds in anything the background refresh hasn't caught up to yet, so a read after a write sees the write. ?synchronous=true still exists, but its meaning is "re-derive everything from the event log, trusting nothing derived" — an audit posture, not a freshness requirement.

Configuration

All aggregate types are cached by default. No configuration needed for most apps.

To exclude specific types from caching, use modules.never_cache:

{
  "aggregate_types": {
    "user": { "events": { "..." : {} } },
    "order": { "events": { "..." : {} } },
    "audit_log": { "events": { "..." : {} } }
  },
  "modules": {
    "never_cache": ["audit_log"]
  }
}

Types in never_cache are always computed fresh from events. Everything else is cached.

Types the platform reads without either

An aggregate type whose every event handler is a single root set from $.data — "the state is whatever the last event said" — is served directly from the stream's last event, in constant time, no cache and no replay. This is automatic, applies to ?synchronous=true as well, and means such types have no staleness window at all: a read is always as fresh as the newest write. If your types have this shape, neither caching configuration nor the fold byte budget affects them.

Reading cached data

GET /{type}/{id} returns cached state automatically:

curl https://myapp.j17.dev/user/abc123 \
  -H "Authorization: Bearer $API_KEY"
# Returns cached aggregate - fast

When you need fresh data

Use ?synchronous=true to bypass the cache and compute from events:

curl https://myapp.j17.dev/user/abc123?synchronous=true \
  -H "Authorization: Bearer $API_KEY"
# Computes from events, guaranteed fresh

Cache invalidation

Caches invalidate automatically when: - New events are written to the aggregate - Spec is redeployed (cache clears automatically)

You do not manage invalidation. The system handles it.

When to cache

Good candidates: - User profiles, settings - Product catalogs - Dashboards and lists - Anything read more often than written

Skip caching for: - Audit logs (append-only, rarely read as aggregates) - Aggregates where even brief staleness is unacceptable (financial balances, inventory counts during checkout)

Staleness expectations

  • Typical: Under 1 second after a write
  • Worst case: ~10 seconds for very large deployments
  • After spec deploy: Cache automatically invalidates

Optimistic UI pattern

Often you don't need to read after a write at all:

  1. User clicks "Update Profile"
  2. You POST the event to j17
  3. On success, immediately update the UI optimistically
  4. Don't bother reading the aggregate back
async function updateProfile(userId, changes) {
  await fetch(`https://myapp.j17.dev/user/${userId}/had_profile_updated`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${apiKey}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      data: changes,
      metadata: { actor: { type: "user", id: userId } }
    })
  });

  // Update UI immediately - don't wait for read
  updateLocalState(changes);
  showToast("Profile updated!");
}

Why this works: Event writes are atomic. If the POST succeeds, the event is persisted. The cache will catch up. Your user sees instant feedback.

Handling the rare failure: If the write fails (validation error, network issue), show an error. For conflicts, refetch and let the user retry. Most UIs can show optimistic updates and handle the rare failure gracefully.

Optimistic updates with OCC

For cases where conflicts matter (like adding items to a shared order):

async function addItem(orderId, item) {
  const current = await fetch(
    `https://myapp.j17.dev/order/${orderId}?synchronous=true`,
    { headers: { "Authorization": `Bearer ${apiKey}` } }
  ).then(r => r.json());

  // Optimistically update UI
  renderOrder({
    ...current.state,
    items: [...current.state.items, item]
  });

  // Write event with OCC
  try {
    await fetch(`https://myapp.j17.dev/order/${orderId}/had_item_added`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        data: item,
        metadata: {
          actor: { type: "user", id: userId },
          previous_length: current.length
        }
      })
    });
  } catch (err) {
    // Conflict - someone else wrote. Refetch and retry.
    const fresh = await fetch(
      `https://myapp.j17.dev/order/${orderId}?synchronous=true`,
      { headers: { "Authorization": `Bearer ${apiKey}` } }
    ).then(r => r.json());
    renderOrder(fresh.state);
  }
}

Note the use of ?synchronous=true to bypass cache when fresh data is needed for conflict detection.

Performance comparison

Without Cache With Cache
Read speed Slower (replays events) Fast (pre-computed)
Resource usage Higher Lower
Consistency Always fresh Eventually consistent
Best for Critical reads Most reads

Best practices

Leave caching on. It is the default and the right choice for most types. Reach for never_cache only when you have a reason.

Opt out of the right types. High-read, low-write aggregates benefit most from caching. Types that change every second, or where staleness is unacceptable, are the ones to list in never_cache.

Use ?synchronous=true sparingly. It bypasses the cache entirely -- use it for critical reads where staleness matters, not as a default.

Monitor your usage. Caching reduces compute usage, which helps you stay within tier limits.

Don't cache user-specific data in shared CDN caches. If you put j17 behind a CDN, use Vary: Authorization to avoid leaking data between users.

See also

Can't find what you need? support@j17.app