DeepSpace apps include a per-app AppJobRoom Durable Object for background work that can’t or shouldn’t run inside an HTTP handler - long AI generations, CSV exports, image renders, bulk imports, fan-out side effects. Jobs are persisted in SQLite, survive isolate restarts, and broadcast every state change over WebSocket so clients see live progress and can cancel or retry without polling.
Use this instead of ctx.waitUntil(...). waitUntil is killed 30 seconds after the response goes out - the JobRoom replaces that pattern. For work that runs on a schedule (daily digest, hourly sync), use scheduled tasks; for privileged work that finishes inside the HTTP response, use server actions.
The JobRoom DO does not enforce a role on enqueue / cancel / retry. Unlike CronRoom, which authorizes writes off the wsRoute resolver role, any signed-in (or anonymous) connection can enqueue jobs. If your handlers spend owner credits via integrations or AI proxies, gate the trigger UI client-side by useUser().user?.role === 'admin' or wrap the route in (protected)/.
When to use jobs vs. cron vs. server actions
| If the work… | Use |
|---|
| Finishes inside the HTTP response | A regular Hono route or server action |
| Runs on a schedule (daily digest, hourly sync) | Scheduled tasks |
| Is triggered by a user click and may take seconds to minutes | Background jobs (this guide) |
| Is triggered by the worker and needs to outlive the response | Background jobs (this guide) |
Define handlers in src/jobs.ts
A single runJob function dispatches every job type. It receives the Job row and a JobContext, returns the result on success, and throws to fail.
import type { Job, JobContext } from 'deepspace/worker'
export async function runJob(
job: Job,
ctx: JobContext,
env: Env,
): Promise<unknown | void> {
if (job.type === 'ai-summarize') {
const { text } = job.payload as { text: string }
ctx.progress(0.1, 'starting')
// Pass ctx.signal so Cancel actually aborts the upstream fetch.
const summary = await callModel(text, { signal: ctx.signal })
return { summary, words: summary.split(/\s+/).length }
}
if (job.type === 'export-csv') {
// ... long export, call ctx.progress(p, msg) periodically
}
// Unknown type → fail loudly so it shows up in the failed list.
throw new Error(`Unknown job type: ${job.type}`)
}
The return value becomes job.result and must be JSON-serializable. Throwing fails the job; if attempts < maxAttempts, the job retries on the next alarm tick. There is no ctx.complete() or ctx.fail() - return or throw to control the outcome.
The JobContext API
| Member | Purpose |
|---|
ctx.progress(value, message?) | Broadcast progress (0..1). Re-renders any useJobs subscriber. |
ctx.signal | AbortSignal that fires when the client calls cancel(id) (same isolate). Pass to fetch(url, { signal: ctx.signal }) to abort upstream requests cleanly. |
ctx.continue(state, { afterMs? }) | Checkpoint state and yield to the next alarm tick. Use for jobs that exceed the ~15-minute wall budget. Call ctx.continue(...) then return - the next tick invokes onJob again with job.resumeFrom = state. |
Worker wiring
The scaffolded worker.ts already wires AppJobRoom:
export class AppJobRoom extends JobRoom<Env> {
constructor(state: DurableObjectState, env: Env) {
super(state, env)
}
protected async onJob(job: Job, ctx: JobContext): Promise<unknown> {
return await runJob(job, ctx, this.env)
}
}
Don’t edit the binding or route - add job types in src/jobs.ts and the DO picks them up.
Enqueueing - two entry points, one queue
Two enqueue paths write to the same DO row. Pick by where the caller lives.
From the client - useJobs
The useJobs hook returns a live jobs list and an enqueue function. Every connected subscriber sees the same state transitions in real time.
import { useJobs } from 'deepspace'
import { SCOPE_ID } from '../constants'
function ExportButton() {
const { enqueue, jobs, cancel, retry } = useJobs(SCOPE_ID)
return (
<>
<button onClick={() => enqueue('export-csv', { filterId: 'q1' }, { maxAttempts: 2 })}>
Run export
</button>
<ul>
{jobs.map((j) => (
<li key={j.id}>
{j.type} - {j.status}
{j.progress != null && <> ({Math.round(j.progress * 100)}%)</>}
{j.status === 'running' && <button onClick={() => cancel(j.id)}>Cancel</button>}
{j.status === 'failed' && <button onClick={() => retry(j.id)}>Retry</button>}
</li>
))}
</ul>
</>
)
}
enqueue resolves with the jobId once the server acks. jobs is sorted with live/recent first and re-renders on every state change.
From the worker - enqueueJob
Use this from HTTP routes, server actions, cron handlers, AI routes - anywhere the JobRoom DO isn’t the current isolate.
import { enqueueJob } from 'deepspace/worker'
app.post('/api/start-export', async (c) => {
const auth = await resolveAuth(c.req.raw, c.env)
if (!auth) return c.json({ error: 'unauthorized' }, 401)
const jobId = await enqueueJob(
c.env.JOB_ROOMS,
`app:${c.env.APP_NAME}`,
'export-csv',
{ filterId: '...' },
{ maxAttempts: 2, enqueuedBy: auth.userId },
)
return c.json({ jobId })
})
Inside AppJobRoom.onJob(...) itself, call this.enqueue('next-step', payload) to chain follow-up work - the in-isolate call skips the HTTP hop that enqueueJob makes from outside:
export class AppJobRoom extends JobRoom<Env> {
protected async onJob(job: Job, ctx: JobContext): Promise<unknown> {
const result = await runJob(job, ctx, this.env)
if (job.type === 'export-csv') this.enqueue('email-export', { jobId: job.id })
return result
}
}
Lifecycle and limits
Every default below is fixed when the DO is constructed. Override them by passing a config object to super(state, env, { ... }) inside AppJobRoom - see the JobRoomConfig reference for every knob.
| Concern | Default | How to change |
|---|
| Retry on throw | None (maxAttempts: 1) | Pass { maxAttempts: N } to enqueue |
| Retry backoff | 1 s | Override retryBackoffMs on AppJobRoom config |
| Terminal-row retention | 24 h | Override retentionMs on AppJobRoom config |
| Per-tick wall budget | ~15 min | Chain with ctx.continue(state) |
| Crash recovery | Auto | Rows stuck running past ~16 min are rescued on the next DO wake-up |
State machine: queued → running → succeeded | failed | canceled.
Outbound calls in handlers
Handlers run as the app owner, just like scheduled tasks. Use createDeepSpaceAI(env, 'anthropic') for AI calls - it falls back to APP_OWNER_JWT and bills the developer. Pass ctx.signal to every fetch(...) so client cancel aborts cleanly upstream:
const res = await fetch('https://api.example.com/render', {
method: 'POST',
body: JSON.stringify({ ... }),
signal: ctx.signal,
})
Auth-gating paid jobs
Per the Warning at the top of this page, the JobRoom DO does not gate writes server-side. For jobs that spend owner credits, gate the trigger UI client-side:
import { useJobs, useUser } from 'deepspace'
import { SCOPE_ID } from '../constants'
function SummarizeButton({ text }: { text: string }) {
const { enqueue } = useJobs(SCOPE_ID)
const { user } = useUser()
if (user?.role !== 'admin') return null
return <button onClick={() => enqueue('ai-summarize', { text })}>Summarize</button>
}
Or wrap the route in (protected)/ for a sign-in floor.
Testing without waiting for a real upstream
Two approaches work well:
- Use a fast handler in tests. A job type like
'echo' that returns its payload with no I/O lets you assert the full enqueue → run → succeed pipeline in under a second without mocking upstreams.
- Hit the enqueue route from a Playwright spec. Render a page that uses
useJobs, click the enqueue button, then assert against the rendered status:
test('export job succeeds end-to-end', async ({ page }) => {
await page.goto('/jobs')
await page.getByRole('button', { name: /run export/i }).click()
await expect(
page.locator('[data-testid="job-row"][data-status="succeeded"]'),
).toBeVisible({ timeout: 30_000 })
})
Don’t write tests that wait for the 16-minute crash-recovery sweep, and don’t manually flip DB rows - use the public enqueue / cancel / retry surface.
Next steps