Essay
Using Cloudflare Durable Objects with SvelteKit: lessons from an AI app
Updated Justin Ahinon
I built naps.sh in about a week. It's an AI-powered idea validation tool. You describe a business idea, an agent runs market research across six phases (keyword analysis, Reddit pain discovery, competitor mapping, funding signals, market sizing), and produces a scored report with a GO/NO-GO verdict.
The whole thing runs on Cloudflare Workers. SvelteKit frontend, D1 for relational data, R2 for file storage, Queues for background jobs. And one Durable Object class that became the center of everything.
This was my first time using Durable Objects. If you're building a SvelteKit app with a long-running AI conversation, here's how I wired up session routing and split the data between D1 and the DO. The stream buffer below shows what the app can recover after an interruption.
Why Durable Objects
Midway through day two, I had a working AI chat using the Vercel AI SDK's streamText. Standard request-response. It worked. But I kept thinking about what happens when the user closes the tab mid-validation. Or opens a second tab. Or comes back an hour later. The validation runs take a few minutes. That's a long time to keep a stateless HTTP connection alive.
I'd been curious about Durable Objects for a while. Never had a clear use case. This felt like one.
Each validation session maps to one DO instance. The DO owns the conversation state, streams responses over WebSocket, persists messages to its SQLite storage, and handles multi-tab coordination. One actor per session, isolated, stateful, long-lived.
The mental model clicked fast. A Durable Object is the session. Not a service that manages sessions. The session itself.
What worked
Per-session isolation is the thing I keep coming back to. Every validation gets its own little world. Its own SQLite database, its own WebSocket connections. No shared state between different users' validations. When I needed to add multi-tab support, the DO already knew about all its connected sockets, so it was just... natural.
WebSocket Hibernation is useful too. With the Hibernation API, an idle DO can leave memory while its clients stay connected. A new event wakes it up. An open socket doesn't have to keep the object running. It can't hibernate while it's still processing a request or event.
And SQLite in the DO is great. I have three tables (messages, metadata, and a stream buffer), reads and writes are fast, and the data lives right next to the compute that needs it. No network hop to a database.
What didn't
The dev experience is rough
In the SvelteKit setup I used, I didn't have hot reload for Durable Objects. Every change meant re-running the full build pipeline. I ended up with concurrently running vite build --watch alongside wrangler dev, but the intermediate build states cause failures because the DurableObject import from cloudflare:workers breaks during partial rebuilds.
Not terrible. But compared to the instant feedback loop you get with regular SvelteKit development, it's a step down that you feel every time.
Exporting the DO class from SvelteKit's Worker
This was the first real friction. SvelteKit's Cloudflare adapter produced a worker entry point without my Durable Object class export. Wrangler couldn't find the class, so the deploy failed.
The SvelteKit issue discussing custom Worker exports helped explain what I was running into. In the adapter setup I used for this app, I had to write a patch-worker.ts script that runs after each build, injecting the DO class export into the generated worker entry.
The relevant part of that patch appends this export to .svelte-kit/cloudflare/_worker.js:
export { ValidateAgent } from '../../src/lib/server/durable-objects/validate-agent';
The class name has to match the binding in wrangler.toml. Here's the corresponding configuration from the app:
[durable_objects]
bindings = [{ name = "VALIDATE_AGENT", class_name = "ValidateAgent" }]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ValidateAgent"]
This is the app's initial SQLite migration. An existing project needs its own migration history. The patch depends on the adapter's generated output, so an adapter upgrade is a reason to check it again.
Once the binding exists, the SvelteKit WebSocket route chooses the object by session ID. This is the final part of my src/routes/ws/+server.ts, after checking the WebSocket upgrade, authentication, session ID, and ownership:
const doId = platform!.env.VALIDATE_AGENT.idFromName(`session:${sessionId}`);
const stub = platform!.env.VALIDATE_AGENT.get(doId);
const doUrl = new URL(request.url);
doUrl.searchParams.set('userId', user.id);
return stub.fetch(new Request(doUrl, request));
Two tabs opening the same session resolve to the same object within that namespace. The route checks ownership before forwarding the request, because knowing a session ID should never be enough to open its conversation. The DO then accepts the WebSocket and owns the conversation.
Preview URLs and staging are different
I hit this when trying to test a deployment on a preview URL. Cloudflare's version preview URLs aren't generated for Workers that implement Durable Objects.
For a deployed test, Wrangler environments can give a staging Worker its own DOs and persistent storage. You declare its Durable Object bindings separately, because environments don't inherit them. For this app, I'd also give staging separate D1, R2, and Queue bindings so a test session doesn't write into production resources.
The split storage problem
This one crept up on me. The DO stores conversation messages in its SQLite. But session metadata (title, status, user ownership, credits) lives in D1, because that data needs to be queryable across sessions for list pages, admin views, auth checks.
Two databases representing different parts of the same session. The conversation belongs to the DO; D1 holds ownership and a queryable view of progress.
It works, but the seams show. When the validation finishes, I need to update status in both the DO and D1. When the list page loads, I can't ask the DOs for their state. I need D1 to have it already.
The fan-out trap
This is the split storage problem taken to its logical conclusion. I built a /validations page that shows all your past sessions with their current status. My first instinct was to fetch each DO to get its state.
Twenty sessions means forty DO stub calls (status + report status) blocking server-side rendering. That's not going to work.
The solution is obvious in hindsight: push state changes to D1 so the list page queries one table. But it means you're constantly syncing state from the DO to D1, and you have to be disciplined about it. Every status transition in the DO needs a corresponding D1 write. The DO writes to SQLite and updates D1 in a separate call. If the D1 update fails, the list page can show stale progress. A retry or a reconciliation step would need to bring that D1 row up to date.
You can't query across DOs. That's by design. If your UI needs to show state from many instances at once, you need a denormalized read store somewhere else. I wish I'd thought about this on day one instead of day five.
The stream buffer pattern
A deploy or runtime restart can interrupt a Durable Object. Its lifecycle means I can't rely on in-memory state surviving. The SQLite survives, but your local variables (the streaming response, the accumulated chunks, the abort controller) vanish.
For a validation that takes several minutes, this is a real problem. A user could be watching their report stream in and suddenly... nothing.
I built a two-layer recovery for this:
-
While streaming, every chunk gets accumulated in memory (for fast replay to reconnecting tabs) and appended to a
stream_buffertable in SQLite. Astream_activeflag is set in metadata before streaming starts. -
If a client reconnects and finds
stream_activeis true but there's no in-memory state (meaning the previous stream is no longer running in this instance), the DO replays all buffered chunks from SQLite and appends a message: "Research was interrupted by a server update." -
When a new message arrives, the DO checks for an interrupted stream first, extracts any partial text from the buffer, saves it to the messages table, and clears the buffer.
The buffer stores each stream event as JSON in a numbered row. These are the app's SQL operations, collected here using the DO's this.ctx.storage.sql handle:
const sql = this.ctx.storage.sql;
// Initialize when the object starts.
sql.exec(`
CREATE TABLE IF NOT EXISTS stream_buffer (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
chunk TEXT NOT NULL
)
`);
// Inside the stream loop, where chunk is the current stream event.
sql.exec('INSERT INTO stream_buffer (chunk) VALUES (?)', JSON.stringify(chunk));
// On recovery, replay in the order the events were saved.
const rows = sql.exec<{ chunk: string }>(
'SELECT chunk FROM stream_buffer ORDER BY seq'
).toArray();
Replaying those rows sends the saved events back to the client in order. When saving an interrupted response into conversation history, the app extracts the text deltas from those events. Only chunks already written to SQLite are recoverable, and replay doesn't restart an interrupted model call or tool execution.
The validation doesn't resume automatically; it recovers the saved output. The user gets the persisted partial response and can send a new message to continue the conversation. I'm happy with how this turned out.
The existential question
Around day five, I stopped and asked myself: is Durable Objects the right abstraction for what I'm building?
The answer was yes. Per-session AI agents with WebSocket streaming and persistent state, that's the textbook DO use case. I wouldn't choose anything else for this specific problem.
But the DO class grew into a 400+ line god object handling WebSockets, AI streaming, SQL operations, session lifecycle, multi-tab coordination, and report triggering. I should have been more intentional about the boundaries earlier.
And I should have thought harder upfront about what data lives in the DO versus what lives in a shared database. The split storage problem doesn't go away with better code, only with better planning.
If you're starting with DOs
The one thing I'd say: figure out your read patterns before you write any code. The moment you need a list view or an aggregate query across instances, you need a separate data store. This is the thing that will bite you if you don't plan for it.
Build for deploy interruptions from day one. Your DO will be evicted. It might happen mid-operation. If you're doing anything long-running, you need a recovery strategy.
And accept that the developer experience is behind what you're used to with modern frameworks. Budget time for build pipeline work. I spent more time on patch-worker.ts and dev server configuration than I'd like to admit.
I'd use Durable Objects again. The constraints are real but so is the model. Once it clicks, it clicks.