Back to article index
TRANSMISSION / CYAN8 min read

Building Stateful Applications at the Edge

Cloudflare Workers are fast and D1 is close, but the hard part is designing clear boundaries for state, consistency, and failure.

#Cloudflare#D1#Engineering
Edge nodes forming a stateful application topology around a central database
State, edge nodes, and consistency boundaries

The most compelling promise of edge computing is that it brings code closer to users. But once an application starts storing accounts, sessions, and comments, the question is no longer simply how to deploy a function to more locations. It becomes: Where does state actually live, who is responsible for it, and how does the system degrade when something fails?

This article uses a blog with authentication and comments as a minimal example to examine the design boundaries that matter when building a stateful application on Cloudflare Workers and D1.

Start by mapping the state

Even a small blog contains at least three very different kinds of state:

StateSourceLifecycle
Article contentMarkdown in GitUpdated with deployments
Users and commentsD1Long-term persistence
Login sessionsD1 + CookieExplicit expiration

Article content belongs with the code version. It needs review, rollback, and build-time validation, but readers do not need to modify it at runtime. User data is the opposite: it must survive independently of the deployment lifecycle.

Before choosing a database, decide whether a piece of data should follow code versions or user behavior.

That distinction naturally leads to a simple architecture: Nuxt Content owns the Markdown, D1 owns runtime business data, and the Worker combines them for each request.

Bindings matter more than network requests

A Worker does not need to store an API token to access D1, and it should not call the Cloudflare REST API at runtime. D1 enters the runtime directly through a binding:

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "signal-log-db",
      "database_id": "<YOUR_DATABASE_ID>"
    }
  ]
}

In a server route, the database object comes from the current request context. Every SQL query uses a prepared statement:

const comment = await db
  .prepare(`
    SELECT id, body, created_at
    FROM comments
    WHERE post_slug = ?1 AND status = 'published'
    ORDER BY created_at DESC
    LIMIT 100
  `)
  .bind(postSlug)
  .all()

Parameter binding is not merely a matter of code style; it is part of the input boundary. Article paths, email addresses, display names, and comment bodies all come from users. None of them should ever be concatenated into SQL.

Store only irreversible session digests

After a successful login, the browser receives a random session token while the database stores only its SHA-256 digest. If the session table is accidentally exposed, the original tokens still cannot be used directly to sign in.

const token = createRandomToken()
const tokenHash = await sha256(token)

await db
  .prepare('INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)')
  .bind(tokenHash, userId, expiresAt)
  .run()

The cookie uses HttpOnly so frontend JavaScript cannot read it, SameSite=Lax to reduce cross-site request risk, and Secure in production.

This does not make the system “absolutely secure.” A more accurate description is that every layer holds only the minimum capability it needs to do its job.

Design consistency around user actions

D1 is a relational database, but an edge application still needs to identify which flows require strong consistency.

For a blog, there are only a few critical actions:

  1. A newly registered user should be able to sign in immediately.
  2. A successfully submitted comment should appear on the current page immediately.
  3. An old session should become invalid immediately after logout.

These flows should write to and immediately read from the same primary database. Read-only scenarios such as article lists and public comment lists have much more room for caching.

Do not begin with “Where can we add a cache?” Begin with “What promise did the user just complete?”

Failure also needs product design

Edge runtimes can fail, and databases can become temporarily unavailable. The difference is whether those failures become states that users can understand.

  • When login fails, do not distinguish between “email not found” and “wrong password,” because that would expose account-enumeration information.
  • When a comment write fails, preserve the draft so the user can retry.
  • When a content query fails, return a real error state instead of a blank page.
  • Use structured logs for server errors, but never send internal SQL or stack traces to the browser.

Reliability does not mean “nothing ever breaks.” It means the boundaries remain intact when something does.

Finally, keep the architecture boring

This design has no message queue, distributed lock, or separate identity service. That is not because those tools have no value, but because the current problem has not asked for them.

A maintainable personal site usually starts with:

  • Content follows Git.
  • Runtime data goes into D1.
  • Secrets stay in Worker secrets.
  • Inputs are validated at the boundary.
  • Failures are clear, observable, and recoverable.

When traffic or product requirements actually change, let the architecture evolve with the evidence. The edge is already fast enough; engineering’s job is to keep it simple.

End of signal
READER CHANNEL

Comments

00
No comments yet. Be the first to respond.