Skip to content

Write Buffering Patterns

Intermediate

Durability and throughput patterns for absorbing high-frequency writes before a slow or rate-limited canonical datastore. Covers pattern selection, single-writer coordination, constraint tables, and concrete edge-stack mechanics.

Key Facts

  • D1 (SQLite-at-edge) write latency: 200-300 ms (global primary routing, single-writer SQLite); read P99 ~8 ms. Source: production post-mortems, 2026.
  • D1 .batch() benchmark: 5 000 rows in 14 ms batched vs 78 ms sequential. Source: rxliuli.com D1 optimization study.
  • D1 hard cap: 10 GB per database. Plan migrations before approaching this limit.
  • D1 batch constraint: max 100 bound parameters per statement; at 10 columns → 10 rows per statement → chunk accordingly.
  • caching and performance covers write-through/write-behind definitions at a conceptual level; this article focuses on durability mechanics and coordination primitives.
  • Cloudflare KV eventual-consistency window: 60 s global propagation; 1 write/s per key rate limit; read-your-own-write only within a single PoP.
  • Durable Object (DO) in-memory write: < 1 ms; ~500-1 000 req/s per instance for moderate logic; instances are co-located with routing Worker at same PoP.
  • Cloudflare Queues: max_batch_size 1-100 (default 10), max_batch_timeout 0-60 s (default 5 s); at-least-once delivery; 1 M ops/month included then $0.40/M. Source: Cloudflare Queues docs.
  • DO pricing: $0.15/M requests, 50 M rows written/month included then $1.00/M. Source: Cloudflare DO pricing.
  • Offline-first sync batch size: 50 items/chunk is an empirically validated sweet spot balancing request size and partial-failure recovery.

Pattern Catalog

Write-Through

Every write hits the fast store (cache/buffer) and the canonical datastore synchronously before the client receives an ACK.

  • Latency: full datastore round-trip visible to client (e.g. 200-300 ms for D1)
  • Durability: strong - no separate flush needed
  • When to use: low-volume writes where consistency matters more than speed; financial ledgers; operations that must be immediately readable by other services

Write-Back (Write-Behind)

Client writes to a fast buffer; an asynchronous process flushes accumulated entries to the canonical store in batches.

  • Latency for client: sub-millisecond (buffer write only)
  • Durability risk: buffer loss between write and flush
  • Coalescing benefit: 100 updates to the same key = 1 datastore write
  • When to use: high-frequency user-state writes (progress, preferences, counters); game state; any workload where per-operation DB latency is unacceptable
  • Mitigation for loss risk: persist buffer to durable storage (DO Storage / Redis) immediately on receipt; in-memory copy serves reads, durable copy survives eviction

Dirty-Flag (Game-Server Tick Pattern)

Server holds a mutable in-memory state object. Fields are tagged dirty on mutation. A periodic flush ("tick") writes only dirty fields to the datastore and clears flags.

  • Granularity: field-level, not operation-level - reduces write amplification
  • Conflict resolution: last-write-wins (LWW) by timestamp is the standard resolution strategy when multiple clients may update the same field independently
  • When to use: entity state that changes many times between persists (word-learning status, game entity position, form drafts); multi-device sync where a single canonical field value per entity is acceptable
// Conceptual dirty-flag flush inside a single-writer coordinator
class EntityBuffer {
  state = {};    // { fieldName: { value, ts, dirty } }

  mutate(field, value, ts) {
    const current = this.state[field];
    if (!current || ts > current.ts) {
      this.state[field] = { value, ts, dirty: true };
    }
  }

  dirtyFields() {
    return Object.entries(this.state)
      .filter(([, v]) => v.dirty)
      .map(([k, v]) => ({ field: k, value: v.value, ts: v.ts }));
  }

  markClean() {
    for (const k of Object.keys(this.state)) this.state[k].dirty = false;
  }
}

Batch-Flush

Accumulate writes in a buffer (in-memory or durable queue) and drain to the datastore in a single transactional batch on a time or size trigger.

  • Trigger options: elapsed time (alarm/cron), buffer size threshold, explicit client signal (navigation, tab close, reconnect)
  • Atomicity: use DB-level batch/transaction so partial failures leave no half-written state; idempotent inserts (INSERT OR REPLACE / ON CONFLICT DO UPDATE) protect against at-least-once re-delivery
  • When to use: any write-back implementation; queue consumer writing to DB; ETL micro-batch; sync-on-reconnect for offline clients
-- LWW upsert: newer timestamp wins, avoids overwriting fresher data
INSERT INTO entity_state (user_id, key, value, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, key)
DO UPDATE SET
  value      = CASE WHEN excluded.updated_at > entity_state.updated_at
                    THEN excluded.value ELSE entity_state.value END,
  updated_at = MAX(excluded.updated_at, entity_state.updated_at);

Event Sourcing for User State

Append-only event log; current state is derived by replaying or compacting the log. Periodic snapshots cap replay cost.

  • Write path: every state change is an immutable append (e.g. {userId, event: "saved", wordId, ts}) - never an in-place update
  • Read path: replay from last snapshot + subsequent events, or query materialized view
  • Coalescing via log compaction: "user saved word X" then "user ignored word X" → compaction retains only the latter; reduces canonical store size
  • Offline-first fit: client accumulates events in local store (IndexedDB / SQLite); syncs the entire event batch on reconnect; server merges by timestamp
  • When to use: audit trail requirements; multi-device conflict resolution where full history matters; workloads where replaying events is acceptable over maintaining a mutable state table
  • When NOT to use: simple CRUD state with no history value; high-volume counters where compaction overhead exceeds benefit

Queues vs Single-Writer Coordinator

Dimension Message Queue (e.g. CF Queues) DO-style Coordinator
Delivery guarantee At-least-once Exactly-once (single writer)
Client ACK Fire-and-forget; no read-after-write guarantee Synchronous ACK; client can read own writes
Ordering Per-message FIFO within queue; no entity-level ordering Per-entity serial (one instance = one entity)
Coalescing None - each message is discrete Full coalescing in-memory buffer
Cost @ 750 K writes/day ~$0.50/month (after free tier) ~$3/month (request cost)
Best for One-way pipelines, analytics, audit logs, at-least-once acceptable Interactive writes, user state, read-after-write required

Single-Writer Buffering — Durable Object Coordinator

A single DO instance per entity (user, session, game entity) serializes all writes, holds an in-memory buffer, and uses a scheduled alarm to flush to the canonical store.

Why single-writer matters

SQLite and similar single-writer stores serialize concurrent writes internally, causing queuing under load. Pre-serializing at the coordinator layer converts N concurrent client writes into a single scheduled batch, removing contention.

Alarm-flush pattern

// Cloudflare Durable Object - UserStateBuffer
export class UserStateBuffer extends DurableObject {
  buffer = [];  // in-memory; fast, lost on eviction

  async sync(payload) {
    // Persist to DO Storage first for durability across evictions
    const id = crypto.randomUUID();
    this.ctx.storage.sql.exec(
      'INSERT INTO pending (id, data) VALUES (?, ?)',
      id, JSON.stringify(payload)
    );
    this.buffer.push({ id, ...payload });

    // Schedule flush only if no alarm is already set
    if (!(await this.ctx.storage.getAlarm())) {
      await this.ctx.storage.setAlarm(Date.now() + 30_000); // 30 s window
    }
    return { ok: true };  // client ACK before D1 write
  }

  async alarm() {
    // Drain DO Storage (covers eviction scenario where in-memory buffer was lost)
    const rows = [...this.ctx.storage.sql.exec('SELECT id, data FROM pending').results];
    if (rows.length === 0) return;

    const stmts = rows.flatMap(r => buildD1Statements(JSON.parse(r.data)));
    await this.env.DB.batch(stmts);

    // Remove flushed entries
    const ids = rows.map(r => r.id);
    for (const id of ids) {
      this.ctx.storage.sql.exec('DELETE FROM pending WHERE id = ?', id);
    }
    this.buffer = this.buffer.filter(b => !ids.includes(b.id));

    // Reschedule if new writes arrived during flush
    if (this.buffer.length > 0) {
      await this.ctx.storage.setAlarm(Date.now() + 5_000);
    }
  }
}
// Worker: route to per-user DO
async function handleSync(request, env) {
  const { userId, ...payload } = await request.json();
  const stub = env.USER_BUFFER.get(env.USER_BUFFER.idFromName(userId));
  return Response.json(await stub.sync(payload));
}
// Helper: chunk rows respecting D1's 100 bound-parameter limit
function buildD1Statements(payload) {
  const stmts = [];
  if (payload.words) {
    for (const chunk of chunkArray(payload.words, 10)) { // 10 rows × 10 cols = 100 params
      stmts.push(buildUpsert('entity_state', chunk));
    }
  }
  return stmts;
}

function chunkArray(arr, size) {
  const out = [];
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
  return out;
}

Output gates and visibility

DO storage writes without an intervening await are automatically coalesced into a single implicit transaction (Cloudflare write coalescing). The client ACK returns before D1 commit, so optimistic UI updates must reconcile on reconnect using server timestamps.

Constraints Table

Store Write Latency Size Cap Consistency Window Rate Limit
D1 (SQLite-at-edge) 200-300 ms (global primary) 10 GB hard Strong (single-writer) Concurrent write contention
DO Storage (SQLite) < 5 ms (local to PoP) 1 GB per instance Strong (local) ~500-1 000 req/s per instance
Cloudflare KV 5-10 ms write No hard cap 60 s eventual global 1 write/s per key
Cloudflare Queues 5-10 ms enqueue Per-queue limits At-least-once; no read-after-write 400 msg/s default
Redis (self-hosted) < 1 ms RAM-bound Configurable (AOF/RDB) Depends on instance

Offline-First Sync Batch

Client accumulates writes locally (IndexedDB for PWA, SQLite for native mobile) and replays the batch on reconnect or navigation events.

// Client-side sync trigger
async function syncPendingWrites() {
  const pending = await localDb.getAll('pending_writes'); // IndexedDB
  if (pending.length === 0) return;

  // Chunk to 50 items per request to limit payload and enable partial retry
  for (const chunk of chunkArray(pending, 50)) {
    const res = await fetch('/api/sync', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ client_ts: Date.now(), items: chunk }),
    });
    if (res.ok) {
      const ids = chunk.map(w => w.id);
      await localDb.deleteBatch('pending_writes', ids);
    }
    // On failure: leave chunk in local store, retry on next sync cycle
  }
}

// Trigger points
window.addEventListener('online', syncPendingWrites);
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') syncPendingWrites();
});

Conflict resolution: LWW by client_ts is sufficient for independent per-user state (vocabulary, preferences). For collaborative entities, prefer vector clocks or operational transforms (see distributed systems fundamentals).

Gotchas

  • Issue: DO evicted from memory before alarm fires; in-memory buffer lost, writes silently dropped. -> Fix: Always write to DO Storage (durable SQLite) on receipt, keep in-memory copy only as a read-optimization. alarm() drains Storage, not this.buffer. Verify: restart the DO (let it idle 60 s) and confirm Storage still contains pending rows.

  • Issue: KV used as a write queue; at peak load (> 1 write/s per key) the rate limit triggers 429s; 60 s eventual-consistency window breaks read-after-write assumptions (client writes, immediately reads, gets stale value from a different PoP). -> Fix: Do not use KV as a mutable write queue. Use Queues (one-way pipeline, no read-after-write) or DO (serialized per-entity with immediate local read). KV is appropriate for coarse-grained shared config, not per-user mutable state.

  • Issue: D1 10 GB hard cap reached without warning; all writes begin failing. -> Fix: Monitor d1_storage_bytes metric; plan shard or migrate to Postgres/Hyperdrive before reaching ~8 GB. D1 is suitable for apps with bounded or archivable datasets; high-growth user-state tables need a migration path defined upfront.

  • Issue: At-least-once Queue delivery causes duplicate rows when consumer crashes mid-batch. Idempotency not implemented, resulting in phantom duplicates. -> Fix: Use INSERT OR REPLACE / ON CONFLICT DO UPDATE keyed on a stable (entity_id, event_ts) composite. Never use plain INSERT for Queue-sourced writes.

  • Issue: D1 batch silently truncates when a single statement exceeds 100 bound parameters (e.g. bulk-inserting 15 rows with 10 columns = 150 params). The batch proceeds for other statements but the oversized statement is dropped. -> Fix: Chunk input at floor(100 / column_count) rows per statement before constructing the batch. Enforce this in the helper that builds D1 statements, not at the call site.

See Also

Source research: - Cloudflare DO: Rules of Durable Objects - Cloudflare Blog: SQLite in every Durable Object - Cloudflare D1 Limits - Cloudflare Queues: Batching and Retries - Cloudflare KV: How KV Works - Microsoft Azure: Event Sourcing Pattern - AWS: Database Caching Strategies Using Redis