Files
gh-jezweb-claude-skills-ski…/references/best-practices.md
2025-11-30 08:24:21 +08:00

14 KiB

Cloudflare Workers KV - Best Practices

This document contains production-tested best practices for Cloudflare Workers KV.


Table of Contents

  1. Performance Optimization
  2. Caching Strategies
  3. Key Design
  4. Metadata Usage
  5. Error Handling
  6. Security
  7. Cost Optimization
  8. Monitoring & Debugging

Performance Optimization

1. Use Bulk Operations

Bad: Individual reads

const value1 = await kv.get('key1'); // 1 operation
const value2 = await kv.get('key2'); // 1 operation
const value3 = await kv.get('key3'); // 1 operation
// Total: 3 operations

Good: Bulk read

const values = await kv.get(['key1', 'key2', 'key3']); // 1 operation
// Total: 1 operation

Benefits:

  • Counts as 1 operation against the 1000/invocation limit
  • Faster execution
  • Lower latency

2. Use CacheTtl for Frequently-Read Data

Bad: No edge caching

const value = await kv.get('config'); // Fetches from KV every time

Good: Edge caching

const value = await kv.get('config', {
  cacheTtl: 300, // Cache at edge for 5 minutes
});

Guidelines:

  • Use cacheTtl for data that changes infrequently
  • Minimum: 60 seconds
  • Typical values:
    • Configuration: 300-600 seconds (5-10 minutes)
    • Static content: 3600+ seconds (1+ hour)
    • Frequently changing: 60-120 seconds

Trade-off: Higher cacheTtl = faster reads but slower updates propagate


Bad: Many small keys

await kv.put('user:123:name', 'John');
await kv.put('user:123:email', 'john@example.com');
await kv.put('user:123:age', '30');

// Reading requires 3 operations
const name = await kv.get('user:123:name');
const email = await kv.get('user:123:email');
const age = await kv.get('user:123:age');

Good: Coalesced key

await kv.put('user:123', JSON.stringify({
  name: 'John',
  email: 'john@example.com',
  age: 30,
}));

// Reading requires 1 operation
const user = await kv.get<User>('user:123', { type: 'json' });

Benefits:

  • Fewer operations
  • Single cache entry
  • Faster reads

When to use:

  • Related data that's always accessed together
  • Data that doesn't update frequently
  • Values stay under 25 MiB total

4. Store Small Values in Metadata

Bad: Separate keys for metadata

await kv.put('user:123', 'data');
await kv.put('user:123:status', 'active');

// List requires additional get() calls
const users = await kv.list({ prefix: 'user:' });
for (const key of users.keys) {
  const status = await kv.get(`${key.name}:status`); // Extra operation!
}

Good: Metadata pattern

await kv.put('user:123', 'data', {
  metadata: { status: 'active', plan: 'pro' },
});

// List includes metadata, no extra get() calls!
const users = await kv.list({ prefix: 'user:' });
for (const key of users.keys) {
  console.log(key.name, key.metadata.status); // No extra operations
}

When to use:

  • Values fit in 1024 bytes
  • Frequently use list() operations
  • Need to filter/process many keys

Caching Strategies

1. Cache-Aside Pattern (Read-Through)

async function getCached<T>(
  kv: KVNamespace,
  key: string,
  fetchFn: () => Promise<T>,
  ttl = 3600
): Promise<T> {
  // Try cache
  const cached = await kv.get<T>(key, {
    type: 'json',
    cacheTtl: 300,
  });

  if (cached !== null) return cached;

  // Cache miss - fetch and store
  const data = await fetchFn();
  await kv.put(key, JSON.stringify(data), { expirationTtl: ttl });

  return data;
}

Use when:

  • Data is expensive to compute/fetch
  • Read >> Write ratio
  • Acceptable to serve slightly stale data

2. Write-Through Cache

async function updateCached<T>(
  kv: KVNamespace,
  key: string,
  data: T,
  ttl = 3600
): Promise<void> {
  // Update database
  await database.update(data);

  // Update cache immediately
  await kv.put(key, JSON.stringify(data), { expirationTtl: ttl });
}

Use when:

  • Need cache consistency
  • Write operations are infrequent
  • Cache must always reflect latest data

3. Stale-While-Revalidate

async function staleWhileRevalidate<T>(
  kv: KVNamespace,
  key: string,
  fetchFn: () => Promise<T>,
  ctx: ExecutionContext,
  staleThreshold = 300
): Promise<T> {
  const { value, metadata } = await kv.getWithMetadata<T, { timestamp: number }>(
    key,
    { type: 'json' }
  );

  if (value !== null && metadata) {
    const age = Date.now() - metadata.timestamp;

    // Refresh in background if stale
    if (age > staleThreshold * 1000) {
      ctx.waitUntil(
        (async () => {
          const fresh = await fetchFn();
          await kv.put(key, JSON.stringify(fresh), {
            metadata: { timestamp: Date.now() },
          });
        })()
      );
    }

    return value;
  }

  // Cache miss
  const data = await fetchFn();
  await kv.put(key, JSON.stringify(data), {
    metadata: { timestamp: Date.now() },
  });

  return data;
}

Use when:

  • Fast response time is critical
  • Acceptable to serve slightly stale data
  • Background refresh is acceptable

Key Design

1. Use Hierarchical Namespaces

Good key patterns:

user:123:profile
user:123:settings
user:123:sessions

session:abc123:data
session:abc123:metadata

cache:api:users:list
cache:api:posts:123
cache:db:query:hash123

Benefits:

  • Easy to filter with list({ prefix: 'user:123:' })
  • Easy to invalidate groups
  • Clear organization

2. Use Lexicographic Ordering

Keys are always sorted lexicographically, so design keys to take advantage:

// Date-based keys (ISO format sorts correctly)
'log:2025-10-21:entry1'
'log:2025-10-22:entry1'

// Numeric IDs (zero-padded)
'user:00000001'
'user:00000123'
'user:00001000'

// Priority-based (prefix with number)
'task:1:high-priority'
'task:2:medium-priority'
'task:3:low-priority'

3. Avoid Key Collisions

Bad:

user:123        // User data
user:123:count  // Some counter
user:123        // Different data? Collision!

Good:

user:data:123
user:counter:123
user:session:123

Metadata Usage

1. Track Versions

await kv.put('config', JSON.stringify(data), {
  metadata: {
    version: 2,
    updatedAt: Date.now(),
    updatedBy: 'admin',
  },
});

2. Audit Trails

await kv.put(key, value, {
  metadata: {
    createdAt: Date.now(),
    createdBy: userId,
    accessCount: 0,
  },
});

3. Feature Flags

// Store flags in metadata for fast list() access
await kv.put(`flag:${name}`, JSON.stringify(config), {
  metadata: {
    enabled: true,
    rolloutPercentage: 50,
  },
});

// List all flags without additional get() calls
const flags = await kv.list({ prefix: 'flag:' });

Error Handling

1. Handle Rate Limits (429)

async function putWithRetry(
  kv: KVNamespace,
  key: string,
  value: string,
  maxAttempts = 5
): Promise<void> {
  let attempts = 0;
  let delay = 1000;

  while (attempts < maxAttempts) {
    try {
      await kv.put(key, value);
      return;
    } catch (error) {
      const message = (error as Error).message;

      if (message.includes('429') || message.includes('Too Many Requests')) {
        attempts++;
        if (attempts >= maxAttempts) {
          throw new Error('Max retry attempts reached');
        }

        await new Promise(resolve => setTimeout(resolve, delay));
        delay *= 2; // Exponential backoff
      } else {
        throw error;
      }
    }
  }
}

2. Handle Null Values

// ❌ Bad
const value = await kv.get('key');
console.log(value.toUpperCase()); // Error if key doesn't exist

// ✅ Good
const value = await kv.get('key');
if (value !== null) {
  console.log(value.toUpperCase());
}

// ✅ Good: Default value
const value = await kv.get('key') ?? 'default';

3. Validate Input Sizes

function validateKVInput(key: string, value: string, metadata?: any): void {
  // Key size
  if (new TextEncoder().encode(key).length > 512) {
    throw new Error('Key exceeds 512 bytes');
  }

  // Value size
  if (new TextEncoder().encode(value).length > 25 * 1024 * 1024) {
    throw new Error('Value exceeds 25 MiB');
  }

  // Metadata size
  if (metadata) {
    const serialized = JSON.stringify(metadata);
    if (new TextEncoder().encode(serialized).length > 1024) {
      throw new Error('Metadata exceeds 1024 bytes');
    }
  }
}

Security

1. Never Commit Namespace IDs

Bad:

{
  "kv_namespaces": [
    {
      "binding": "MY_KV",
      "id": "abc123def456..." // Hardcoded!
    }
  ]
}

Good:

{
  "kv_namespaces": [
    {
      "binding": "MY_KV",
      "id": "${KV_NAMESPACE_ID}" // Environment variable
    }
  ]
}

2. Encrypt Sensitive Data

// Encrypt before storing
const encrypted = await encrypt(sensitiveData, encryptionKey);
await kv.put('sensitive:123', encrypted);

// Decrypt after reading
const encrypted = await kv.get('sensitive:123');
const decrypted = await decrypt(encrypted, encryptionKey);

3. Use Separate Namespaces for Environments

{
  "kv_namespaces": [
    {
      "binding": "MY_KV",
      "id": "production-namespace-id",
      "preview_id": "development-namespace-id"
    }
  ]
}

Cost Optimization

1. Minimize Write Operations (Free Tier)

Free tier limits:

  • 1,000 writes per day
  • 100,000 reads per day

Strategies:

  • Batch writes when possible
  • Use longer TTLs to reduce rewrites
  • Cache data in memory if accessed frequently within same invocation

2. Use Metadata Instead of Separate Keys

Expensive: 3 writes, 3 reads

await kv.put('user:123:status', 'active');
await kv.put('user:123:plan', 'pro');
await kv.put('user:123:updated', Date.now().toString());

Cheaper: 1 write, 1 read

await kv.put('user:123', '', {
  metadata: { status: 'active', plan: 'pro', updated: Date.now() },
});

3. Set Appropriate TTLs

Longer TTLs = fewer rewrites = lower costs

// ❌ Expensive: Rewrites every minute
await kv.put('cache:data', data, { expirationTtl: 60 });

// ✅ Better: Rewrites every hour
await kv.put('cache:data', data, { expirationTtl: 3600 });

Monitoring & Debugging

1. Track Cache Hit Rates

let stats = { hits: 0, misses: 0 };

async function getCached<T>(kv: KVNamespace, key: string): Promise<T | null> {
  const value = await kv.get<T>(key, { type: 'json' });

  if (value !== null) {
    stats.hits++;
  } else {
    stats.misses++;
  }

  return value;
}

// View stats
app.get('/stats', (c) => {
  const total = stats.hits + stats.misses;
  const hitRate = total > 0 ? (stats.hits / total) * 100 : 0;

  return c.json({
    hits: stats.hits,
    misses: stats.misses,
    hitRate: `${hitRate.toFixed(2)}%`,
  });
});

2. Log KV Operations

async function loggedGet<T>(
  kv: KVNamespace,
  key: string
): Promise<T | null> {
  const start = Date.now();
  const value = await kv.get<T>(key, { type: 'json' });
  const duration = Date.now() - start;

  console.log({
    operation: 'get',
    key,
    found: value !== null,
    duration,
  });

  return value;
}

3. Use Namespace Prefixes for Testing

const namespace = env.ENVIRONMENT === 'production' ? 'prod' : 'test';

await kv.put(`${namespace}:user:123`, data);

// Cleanup test data
if (env.ENVIRONMENT === 'test') {
  // Delete all test: keys
  let cursor: string | undefined;
  do {
    const result = await kv.list({ prefix: 'test:', cursor });
    await Promise.all(result.keys.map(k => kv.delete(k.name)));
    cursor = result.list_complete ? undefined : result.cursor;
  } while (cursor);
}

Production Checklist

Before deploying to production:

  • Environment-specific namespaces configured
  • Namespace IDs stored in environment variables
  • Rate limit retry logic implemented
  • Appropriate cacheTtl values set
  • Input validation for key/value/metadata sizes
  • Bulk operations used where possible
  • Pagination implemented correctly for list()
  • Error handling for null values
  • Monitoring/alerting for rate limits
  • Documentation for eventual consistency behavior
  • Security review for sensitive data
  • Cost analysis for expected usage

Common Patterns

1. Session Management

// Store session
await kv.put(`session:${sessionId}`, JSON.stringify(sessionData), {
  expirationTtl: 3600, // 1 hour
  metadata: { userId, createdAt: Date.now() },
});

// Read session
const session = await kv.get<SessionData>(`session:${sessionId}`, {
  type: 'json',
  cacheTtl: 60, // Cache for 1 minute
});

2. API Response Caching

const cacheKey = `api:${endpoint}:${JSON.stringify(params)}`;

let response = await kv.get<ApiResponse>(cacheKey, {
  type: 'json',
  cacheTtl: 300,
});

if (!response) {
  response = await fetchFromAPI(endpoint, params);
  await kv.put(cacheKey, JSON.stringify(response), {
    expirationTtl: 600,
  });
}

return response;

3. Configuration Management

// Update config
await kv.put('config:app', JSON.stringify(config), {
  metadata: {
    version: 2,
    updatedAt: Date.now(),
    updatedBy: adminId,
  },
});

// Read config (with long cache)
const config = await kv.get<AppConfig>('config:app', {
  type: 'json',
  cacheTtl: 3600, // Cache for 1 hour
});

References