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}$/): Constraininginbox_idto six lowercase hexadecimal characters keeps the primary key B-tree compact and eliminates URL-encoding edge cases across HTTP paths and SMTPRCPT TOenvelopes. - 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 > ?andexpires_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_jsonlets 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:
- The ingestion runtime exits in under a millisecond without allocating memory for
Uint8Arraystream chunks or attachment buffers. - Well-behaved ESPs (SendGrid, Postmark, Amazon SES, Mailgun) record a hard bounce on the
550response 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:
- Logical expiration at read time: Every read and SMTP check filters on
expires_at > now(or overwrites rows whereexpires_at <= now). The exact secondnowpassesexpires_at, the inbox is logically expired and unreachable, even before physical row cleanup runs. - 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
- How tenmin.app works under the hood: ephemeral edge email architecture covers the complete end-to-end topology connecting inbound SMTP routing, MIME parsing, SQLite leases, and isolated browser rendering.
- How RFC 5322 MIME parsing works in serverless edge environments explains how raw inbound byte streams are decoded once the active lease check passes.
- Preventing XSS when rendering untrusted HTML emails in a browser details how stored HTML payloads are sanitized and isolated on the client.
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.