Caching
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:
- User clicks "Update Profile"
- You POST the event to j17
- On success, immediately update the UI optimistically
- 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
- Reading aggregates - Query parameters
- Projections guide - Multi-aggregate views that also benefit from caching