DO vs KV Analysis: Logs and Maintenance Cache
Question: Should we use Durable Objects (DO) or Cloudflare KV for logs and maintenance caching? Is the timestamp/indexed storage plan with pagination (500-1K per read) practical?
Short answer: DO is the right choice for both logs and maintenance cache. Your timestamp/indexed + pagination plan is solid and practical. Here's why.
1. DO vs KV: Quick Comparison
| Feature | DO | KV |
|---|---|---|
| Consistency | Strong (ACID) | Eventual (eventual consistency) |
| Transactions | Yes | No |
| Latency | <10ms (hot), <50ms (warm) | <10ms (global) |
| Storage | 1GB per instance | Unlimited (but per-key limits) |
| Query complexity | Can do aggregation/filtering in DO | Key-value only (no queries) |
| Cost | Included in Workers | $0.50 per million reads |
| Use case fit | Cache (needs consistency), logs (needs queries) | Simple key-value, eventual consistency OK |
2. For Maintenance Cache: DO Wins
Why DO:
- Strong consistency: Maintenance cache must be consistent. If a request is updated, all Workers must see the update immediately. KV's eventual consistency can cause stale reads (e.g., approve → assign → complete might see stale "pending" if KV hasn't propagated).
- Transactional updates: Cache invalidation often needs to update multiple keys atomically (e.g., invalidate request + list cache + permission cache). DO can do this; KV cannot.
- Already using DO: Your cache layer is DO-based (
CepatEdgeCache). Consistency: same storage model, same latency profile, same patterns. - Cost: DO is included in Workers; KV costs per read. Cache is read-heavy → KV costs add up.
KV would be wrong here because:
- Eventual consistency = stale maintenance data = workflow bugs (e.g., approve twice, assign to completed request).
- No transactions = can't atomically invalidate multiple cache keys.
- Extra cost for reads.
Verdict: Stick with DO for maintenance cache. ✅
3. For Usage Logs: DO Also Wins (With Your Plan)
Why DO with timestamp/indexed storage:
Your Plan (Timestamp/Indexed + Pagination)
Storage: usage:log:<timestamp>:<randomId> (per-log keys)
Read: List by prefix (usage:log:), filter by timestamp range, paginate (500-1K per call)Why this works:
- No single giant array: Each log is one key. Append = write one key (fast). Read = list prefix + filter (DO's
storage.list({ prefix, start, end, limit })is optimized for this). - Pagination is efficient: DO's
storage.list()supportslimitand cursor-like pagination. Reading 500-1K keys is fast (<50ms even with filtering). - Timestamp-based queries:
storage.list({ prefix: 'usage:log:', start: 'usage:log:1700000000', end: 'usage:log:1700086400', limit: 500 })gives you exactly what you need. DO storage is sorted by key → timestamp prefix = chronological order. - Scales to 10K+ logs: With per-key storage, 10K logs = 10K keys. DO can handle millions of keys per instance (1GB limit is the constraint, not key count). Each log entry is small (e.g., 200 bytes) → 10K logs ≈ 2MB, well under 1GB.
- From-to support: Your API can accept
fromTimestampandtoTimestamp, build prefix ranges (usage:log:${fromTimestamp}tousage:log:${toTimestamp}), and usestorage.list({ prefix, start, end, limit }). This is exactly what DO is designed for.
KV alternative (why it's worse):
- No queries: KV can't do "list all keys with timestamp between X and Y." You'd need to:
- Store all log keys in a separate index (another KV key = same problem as array)
- Or scan all keys client-side (slow, expensive)
- Or use KV's
list()but it doesn't support range queries like DO
- Eventual consistency: Logs might appear out of order or missing for a few seconds (not critical but annoying).
- Cost: Logs are read-heavy (analytics, dashboards). KV charges per read → expensive at scale.
Verdict: DO with timestamp/indexed storage is the right move. ✅
4. Practical Implementation Notes
Storage Key Format
// Recommended format:
const logKey = `usage:log:${timestamp}:${randomId}`;
// Example: usage:log:1708123456789:abc123
// Why:
// - Timestamp first = chronological order in storage.list()
// - Random ID = avoid collisions if two logs have same timestamp
// - Prefix 'usage:log:' = easy to list all logsReading Logs (Pagination)
// In DO:
async getLogs(fromTimestamp: number, toTimestamp: number, limit: number = 500) {
const prefix = 'usage:log:';
const start = `${prefix}${fromTimestamp}`;
const end = `${prefix}${toTimestamp}`;
const keys = await this.storage.list({
prefix,
start,
end,
limit,
});
// Keys are already sorted (timestamp prefix)
const logs = await Promise.all(
Array.from(keys.keys()).map(key => this.storage.get(key))
);
return logs.filter(Boolean);
}Performance:
- 500-1K keys: <50ms (even with filtering)
- 10K keys total: Still <50ms if you paginate (you're only reading 500-1K at a time)
- Scales to millions of keys (DO storage limit is 1GB, not key count)
Retention / Cleanup
// Option 1: Scheduled cleanup (recommended)
// Run periodically (e.g., daily cron Worker) to delete old logs
async cleanupOldLogs(retentionDays: number = 30) {
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
const prefix = 'usage:log:';
const end = `${prefix}${cutoff}`;
const oldKeys = await this.storage.list({
prefix,
end,
limit: 1000, // Delete in batches
});
await Promise.all(
Array.from(oldKeys.keys()).map(key => this.storage.delete(key))
);
}
// Option 2: TTL per key (if DO supports it in future)
// Not available today, but scheduled cleanup works fine5. Can DO Handle 10K+ Logs?
Yes, easily.
- Storage: 10K logs × 200 bytes ≈ 2MB (well under 1GB limit)
- Read performance: With pagination (500-1K per call), reading 10K logs = 10-20 API calls, each <50ms = total <1 second (acceptable for analytics/dashboards)
- Write performance: Each log = one
storage.put()= <10ms. 10K writes = sequential (if needed) or batched = still fast - Scaling: If you need more than 1GB (millions of logs), shard by time period (e.g.,
usage:log:2024-02:→ separate DO instance per month)
Your plan (500-1K pagination) is practical:
- API calls stay fast (<50ms per page)
- Memory stays low (only load 500-1K logs at a time)
- Scales to millions of logs (just paginate more)
6. Maintenance Cache: Same Pattern?
Yes, but simpler:
- Maintenance cache keys:
maintenance:request:<requestId>,maintenance:list:<userId>:<status>, etc. - No pagination needed (you're caching specific requests/lists, not scanning)
- DO's strong consistency ensures cache invalidation works correctly
Example:
// Cache a request
await cacheDO.fetch(`/cache/maintenance:request:${requestId}`, {
method: 'PUT',
body: JSON.stringify({ data: requestData, expires: ... }),
});
// Invalidate on update (atomic)
await cacheDO.fetch(`/cache/maintenance:request:${requestId}`, {
method: 'DELETE',
});7. Summary: Your Plan is Good ✅
| Aspect | Your Plan | Verdict |
|---|---|---|
| DO vs KV for logs | DO with timestamp/indexed keys | ✅ Correct — DO supports range queries, KV doesn't |
| DO vs KV for maintenance | DO (already using) | ✅ Correct — needs strong consistency |
| Timestamp/indexed storage | usage:log:<timestamp>:<id> | ✅ Correct — enables efficient range queries |
| Pagination (500-1K) | Yes, with from-to support | ✅ Practical — keeps reads fast, memory low |
| Can DO handle 10K+ logs? | Yes, with pagination | ✅ Yes — 10K logs ≈ 2MB, pagination keeps reads <50ms |
| Scaling beyond 10K | Shard by time period if needed | ✅ Good plan — DO per month/year if >1GB |
Recommendation: Proceed with DO for both logs and maintenance cache, using your timestamp/indexed + pagination plan. It's the right architecture for your use case.
8. Implementation Checklist
- [ ] Refactor
logUsage()inCepatEdgeCacheto store per-key (usage:log:<timestamp>:<id>) instead of single array - [ ] Add
getLogs(fromTimestamp, toTimestamp, limit)method in DO that usesstorage.list({ prefix, start, end, limit }) - [ ] Update API endpoint to accept
from,to,limitparams and call DO'sgetLogs() - [ ] Add cleanup job (cron Worker or scheduled call) to delete old logs (e.g., >30 days)
- [ ] Test pagination with 10K+ logs to verify performance (<50ms per page)
- [ ] Document retention policy (e.g., 30 days, configurable)
Reference: See Architectural weak points §3 for the current problem (single array) and required direction (per-key storage).