Skip to content

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

FeatureDOKV
ConsistencyStrong (ACID)Eventual (eventual consistency)
TransactionsYesNo
Latency<10ms (hot), <50ms (warm)<10ms (global)
Storage1GB per instanceUnlimited (but per-key limits)
Query complexityCan do aggregation/filtering in DOKey-value only (no queries)
CostIncluded in Workers$0.50 per million reads
Use case fitCache (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:

  1. 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).
  2. Pagination is efficient: DO's storage.list() supports limit and cursor-like pagination. Reading 500-1K keys is fast (<50ms even with filtering).
  3. 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.
  4. 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.
  5. From-to support: Your API can accept fromTimestamp and toTimestamp, build prefix ranges (usage:log:&#36;{fromTimestamp} to usage:log:&#36;{toTimestamp}), and use storage.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

typescript
// Recommended format:
const logKey = `usage:log:&#36;{timestamp}:&#36;{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 logs

Reading Logs (Pagination)

typescript
// In DO:
async getLogs(fromTimestamp: number, toTimestamp: number, limit: number = 500) {
  const prefix = 'usage:log:';
  const start = `&#36;{prefix}&#36;{fromTimestamp}`;
  const end = `&#36;{prefix}&#36;{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

typescript
// 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 = `&#36;{prefix}&#36;{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 fine

5. 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:

typescript
// Cache a request
await cacheDO.fetch(`/cache/maintenance:request:&#36;{requestId}`, {
  method: 'PUT',
  body: JSON.stringify({ data: requestData, expires: ... }),
});

// Invalidate on update (atomic)
await cacheDO.fetch(`/cache/maintenance:request:&#36;{requestId}`, {
  method: 'DELETE',
});

7. Summary: Your Plan is Good ✅

AspectYour PlanVerdict
DO vs KV for logsDO with timestamp/indexed keys✅ Correct — DO supports range queries, KV doesn't
DO vs KV for maintenanceDO (already using)✅ Correct — needs strong consistency
Timestamp/indexed storageusage: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 10KShard 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() in CepatEdgeCache to store per-key (usage:log:<timestamp>:<id>) instead of single array
  • [ ] Add getLogs(fromTimestamp, toTimestamp, limit) method in DO that uses storage.list({ prefix, start, end, limit })
  • [ ] Update API endpoint to accept from, to, limit params and call DO's getLogs()
  • [ ] 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).