Designing for the edge: state without a server
For a decade the answer to “where does state live?” was easy: a long-running server with memory, maybe a sticky session, maybe a connection pool to a database three milliseconds away. The edge breaks all three assumptions at once. Your code now runs in dozens of locations, each one cold, short-lived, and far from your primary database.
That sounds like a downgrade. In practice it forces a clarity that most apps were missing anyway: state has a lifetime and a location, and you should know both.
Three kinds of state
Almost everything I reach for falls into one of three buckets. Naming them up front makes the rest of the design fall out naturally.
- Request state — lives for one request. Headers, parsed bodies, the result of an auth check.
- Session state — lives across a user’s requests. Who they are, their cart, a draft.
- Shared state — lives across users. Inventory counts, a live document, a leaderboard.
The edge handles the first for free. The interesting decisions are all about the other two.
Session state belongs in the cookie
The cheapest durable store on the edge is the one that travels with the user. A signed, encrypted cookie gives you a few kilobytes of trustworthy state with zero round-trips. The trick is signing it so the client can hold it but not forge it.
import { SignJWT, jwtVerify } from "jose";
const key = new TextEncoder().encode(env.SESSION_SECRET);
export async function seal(payload: Session): Promise<string> {
return new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("7d").sign(key);
}
export async function open(token: string) {
const { payload } = await jwtVerify(token, key);
return payload as Session; // typed, verified, no DB hit
}
Reach for a database session only when the payload outgrows a cookie, or when you need to revoke it server-side. Until then, the cookie is faster, simpler, and survives a cold start because there’s nothing to warm up.
If state can live with the user, let it. The most resilient request is the one that never has to phone home.
Shared state wants a single owner
The hard case is state many users mutate at once — a live cursor, a stock count, a chat room. Spreading that across the edge invites race conditions. The pattern that actually works is to give each piece of shared state one authoritative home and route every write through it.
Platforms expose this as Durable Objects, or you emulate it with a single-writer queue. Either way the shape is the same: reads fan out, writes funnel in.
// One instance per room id — the edge routes you to it.
export class Room {
count = 0;
async fetch(req: Request) {
if (req.method === "POST") this.count++;
return Response.json({ count: this.count });
}
}
A rule of thumb
When you’re unsure where a piece of state goes, ask two questions in order: how long does it need to live, and who is allowed to write it. The answers map almost one-to-one onto cookie, edge cache, and single-owner object. Get those right and the rest of the system tends to stay boring — which, for infrastructure, is the highest compliment I can give.
Where this leaves us
The edge didn’t take state away; it made us say out loud where each piece belongs. That’s a discipline I now bring back to ordinary servers, too. Name the lifetime, name the owner, and the architecture mostly writes itself.