Technical GuideUpdated 2026-11-01

Migrating ephemeral TTL state from eventually consistent KV caches to serverless SQLite

By Koracle LLC EngineeringSystems Architect··13 min read
serverlessarchitecturesqlitedeveloper

When designing a ten-minute disposable inbox, an eventually consistent distributed key-value (KV) store looks like the obvious storage engine on paper. Every inbox maps to a single key (inbox:<id>), inbound emails overwrite that key with expirationTtl: 600, and you never have to write a single line of cleanup SQL.

In production, however, polling an eventually consistent edge cache every three seconds exposes four architectural flaws: negative-cache propagation delays on newly arrived mail, no way to read remaining TTL from a stored key, full-payload rewrites just to extend a timer, and no mechanism to reject mail for unallocated addresses at the SMTP boundary. Migrating the ephemeral state layer from an eventually consistent KV cache to serverless SQLite solves all four problems. This guide walks through what breaks under distributed KV caching, how a relational lease schema works, and when to choose SQLite over KV for short-lived state at the network edge.

What goes wrong with distributed KV caches for 3-second inbox polling

Edge key-value stores are engineered as high-read, low-write configuration and asset caches replicated across global points of presence (PoPs). That design works well for feature flags, routing tables, and static localization bundles, where writes are infrequent and a 30-to-60-second propagation window across edge caches is harmless. An interactive email inbox has the exact opposite access pattern.

1. The negative-cache trap on immediate polling

Watch the exact sequence of events when a user opens an ephemeral inbox to grab a verification code:

t = 0.0s   Browser generates inbox ID "a94f2c"
t = 0.1s   GET /api/inbox/a94f2c hits nearest edge PoP (e.g. FRA)
           -> kvStore.get("inbox:a94f2c") returns null
           -> Edge PoP caches the miss / empty state locally
t = 2.4s   User submits signup form; sender MTA delivers OTP email
t = 2.8s   Ingestion runtime parses MIME and runs:
           -> kvStore.put("inbox:a94f2c", json, { expirationTtl: 600 })
t = 3.1s   Browser polls GET /api/inbox/a94f2c again at FRA
           -> Edge cache can serve stale null for up to cacheTtl (often 60s)

Because the browser begins polling before the first email arrives, the local PoP frequently caches a key-not-found result right before the ingestion handler writes the new message to backing storage. A user staring at a verification countdown expects a six-digit code in three seconds, not forty-five seconds later when the local cache entry finally invalidates.

2. Blind TTL counters and expensive lease extensions

Calling kvStore.get(key) returns the stored value (and optional static metadata written at put time), but typically does not expose the live remaining expirationTtl ticking down inside the store. If a user refreshes the browser tab four minutes into a session, the API has no authoritative way to report that 360 seconds remain unless it stores a separate timestamp inside JSON metadata and reconciles clock skew manually.

Extending an active inbox timer has a similar flaw: resetting a 600-second TTL in a KV store requires reading the entire serialized message array (including up to 200 KB of HTML per message) into memory and issuing a full put() write back to storage, even when zero messages have changed.

3. Blind catch-all storage writes

When a KV store has no explicit lease record distinguishing an "opened" inbox from an "unopened" address, a catch-all SMTP receiver ends up accepting mail for any syntactically valid recipient, parsing the full MIME stream, and writing it into storage. If an external sender keeps retrying delivery to an inbox that a user closed twenty minutes ago, the ingestion pipeline still pays the CPU and write cost of parsing and storing a message nobody will ever read.

The serverless SQLite lease schema

Routing queries from both the HTTP read service and the SMTP ingestion runtime to a single strongly consistent primary SQLite database eliminates read-after-write lag completely. The entire ephemeral lease model fits in one table and one secondary index:

CREATE TABLE IF NOT EXISTS inboxes (
  inbox_id      TEXT PRIMARY KEY,
  messages_json TEXT NOT NULL DEFAULT '[]',
  expires_at    INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_inboxes_expires_at
  ON inboxes (expires_at);

Three deliberate choices keep row operations fast and predictable:

  • Strict 6-character hex primary key (/^[0-9a-f]{6}$/): Constraining inbox_id to six lowercase hexadecimal characters keeps the primary key B-tree compact and eliminates URL-encoding edge cases across HTTP paths and SMTP RCPT TO envelopes.
  • Integer Unix epoch seconds for expires_at: Storing expiration as a 64-bit integer (Math.floor(Date.now() / 1000) + 600) makes range comparisons (expires_at > ? and expires_at <= ?) fast integer index scans rather than ISO-8601 string parses.
  • Bounded JSON array per active lease: Because an ephemeral inbox lives for only ten minutes and caps retention at 25 messages, storing the bounded message list in messages_json lets every 3-second poll resolve with a single primary-key point lookup instead of a multi-row join.

Explicit lease provisioning and zero-copy TTL extensions

In the HTTP read service, polling GET /api/inbox/:id acts as an explicit lease manager. When a visitor loads the page, the first poll provisions an empty row with a 600-second lease. Subsequent polls return the exact remaining TTL computed from expires_at - now, and clicking Extend updates only the integer timestamp column:

const now = Math.floor(Date.now() / 1000);
const ttl = 600;
const extend = req.query("extend") === "1";

const row = await db
  .prepare("SELECT messages_json, expires_at FROM inboxes WHERE inbox_id = ?1")
  .bind(id)
  .first();

let messages = [];
let expiresAt = now + ttl;

if (!row || row.expires_at <= now) {
  await db
    .prepare(
      "INSERT OR REPLACE INTO inboxes (inbox_id, messages_json, expires_at) VALUES (?1, '[]', ?2)"
    )
    .bind(id, expiresAt)
    .run();
} else {
  messages = JSON.parse(row.messages_json) || [];
  expiresAt = row.expires_at;
  if (extend) {
    expiresAt = now + ttl;
    await db
      .prepare("UPDATE inboxes SET expires_at = ?1 WHERE inbox_id = ?2")
      .bind(expiresAt, id)
      .run();
  }
}

return Response.json({
  inboxId: id,
  address: `${id}@${domain}`,
  ttl: Math.max(0, expiresAt - now),
  count: messages.length,
  messages,
});

Notice what happens as soon as a message is written by the ingestion handler: because both the ingestion handler and the HTTP API read from the same primary SQLite instance, the very next 3-second poll sees the message immediately, with zero negative-cache delay.

Rejecting unallocated mail at the SMTP boundary

Having an explicit inboxes lease table unlocks a major operational optimization in the SMTP ingestion handler: verifying whether an inbox is currently active before buffering raw MIME streams or running MIME parsing.

const now = Math.floor(Date.now() / 1000);
const activeRow = await db
  .prepare(
    "SELECT messages_json, expires_at FROM inboxes WHERE inbox_id = ?1 AND expires_at > ?2"
  )
  .bind(inboxId, now)
  .first();

if (!activeRow) {
  envelope.reject("550 5.1.1 Mailbox expired or not active");
  return;
}

If a marketing server tries to send a follow-up drip email two days after a user's 10-minute session ended, the primary-key lookup returns null and the receiver returns a permanent SMTP 550 rejection during the delivery transaction. Two benefits happen at once:

  1. The ingestion runtime exits in under a millisecond without allocating memory for Uint8Array stream chunks or attachment buffers.
  2. Well-behaved ESPs (SendGrid, Postmark, Amazon SES, Mailgun) record a hard bounce on the 550 response and automatically suppress that address from future sends.

Opportunistic TTL garbage collection without background cron jobs

The one feature sacrificed when moving off a TTL-native KV store is automatic storage-level key deletion. Relational SQLite tables do not delete rows automatically when an integer timestamp passes.

The textbook workaround is scheduling a periodic background cron task (for example, */5 * * * *) that wakes up every five minutes to run DELETE FROM inboxes WHERE expires_at <= ?1. That works, but even when traffic is completely idle overnight, a scheduled job still wakes up 288 times a day.

Instead, expiration can be enforced cleanly in two complementary phases without any scheduled job:

  1. Logical expiration at read time: Every read and SMTP check filters on expires_at > now (or overwrites rows where expires_at <= now). The exact second now passes expires_at, the inbox is logically expired and unreachable, even before physical row cleanup runs.
  2. Asynchronous opportunistic reaping via waitUntil(): At the end of every successful email ingestion, schedule an indexed cleanup query off the critical response path:
await db
  .prepare("UPDATE inboxes SET messages_json = ?1, expires_at = ?2 WHERE inbox_id = ?3")
  .bind(JSON.stringify(list), now + ttl, inboxId)
  .run();

ctx.waitUntil(
  db.prepare("DELETE FROM inboxes WHERE expires_at <= ?1")
    .bind(now)
    .run()
);

Because idx_inboxes_expires_at indexes the expires_at column, SQLite locates and removes expired rows in sub-millisecond time while ctx.waitUntil() lets the SMTP handler acknowledge delivery immediately without blocking on cleanup. Storage only grows when mail arrives, and every arriving message sweeps out expired leases.

Decision matrix: eventually consistent KV vs. serverless SQLite

Requirement                                  Distributed Edge KV            Serverless SQLite
---------------------------------------------------------------------------------------------------
Read-after-write consistency                 Eventually consistent (~60s)   Strongly consistent primary
Negative-cache behavior on missing keys      Caches misses at edge PoP      Direct primary B-tree lookup
Partial column updates (e.g. extend TTL)     No (must rewrite full value)   Yes (UPDATE expires_at = ?1)
Inspect remaining TTL on read                Not exposed natively           Exact (expires_at - now)
Automatic background TTL purge               Native (expirationTtl)         Query filter + async reaper
Ideal workload                               Config, routing, static cache  Interactive state, leases, auth

If your service reads configuration flags, IP allowlists, or precomputed static blobs that change once an hour, stay on an eventually consistent edge KV store; its PoP-level read caching is ideal for read-heavy fan-out. As soon as a user is waiting on a screen for newly written state to appear within seconds, or you need atomic column updates and strict lease validation, move that state to strongly consistent relational or actor-backed storage from day one.

Further reading

Ready when you are

Need an ephemeral test inbox right now?

Generate a fresh @tenmin.app address in one click. Zero registration, sandboxed HTML rendering, automatic 600-second TTL deletion.