Jobs
Work that takes longer than a request: a queue, progress over SSE, and the result.
Some work does not fit in a request: an export of every order, a report over a million rows,
a batch of images. A proxy gives up after a minute, the client after less. app.job takes
the work off the request: one route starts it and answers at once, the others tell how it
goes and hand over what it made.
const = .({ : .(), : .() });
const = .({ : .(), : .() });
.job(
"/exports",
{
: "Export orders",
: .({ : .(), : .() }),
: ,
: ,
: 2,
},
async () => {
const = await orderIds(.input.from, .input.to);
const = createCsv();
for (let = 0; < .length; ++) {
.signal.throwIfAborted(); // canceled, timed out, or the server stops
await .write(await loadOrder([]));
.progress({ : + 1, : .length }); // only the latest is kept
}
return { : await .publish(), : .length };
},
);The work gets job.input (the checked body), job.signal, job.progress(p), its job.id,
and job.ctx, the context of the request that started it, with its decorations and state.
That request is over, so job.ctx.signal throws: use job.signal.
Five routes
app.job("/exports", …) defines five ordinary routes. The docs page, OpenAPI, hooks,
security, the typed client and inkan check see them like any other.
| route | does | answers |
|---|---|---|
POST /exports | starts a job | 202 with the job and a location; 503 when the queue is full or the server stops |
GET /exports/:id | how it stands | 200 with the job; 404 |
GET /exports/:id/events | follows it as server-sent events | 200, a stream; 404 |
GET /exports/:id/result | what it made, ?wait= up to 30 seconds | 200 with the result; 409 while it is not done, or when it failed or was canceled; 404 |
DELETE /exports/:id | cancels it, or forgets it once it is over | 202 with the job; 204 once forgotten; 404 |
A job, as the routes answer with it:
{
"id": "q3Z0…",
"state": "running",
"progress": { "done": 120, "total": 4000 },
"createdAt": "2026-10-09T09:12:01.000Z",
"startedAt": "2026-10-09T09:12:01.004Z"
}state is queued, running, done, failed or canceled. A waiting job has a
position, 1 for next. A failed one has an error: the problem it threw, or a 500 problem
for anything else (its message only in development). In OpenAPI the schema is called
ExportsJob, after the path; name changes it.
Following it
/events sends the job's status first, then its progress as it changes, then end
with the job as it ended, and closes. A slow reader gets the latest progress only: nothing
piles up. The typed client reads it with client.events:
const = await shop.post("/exports", { : { , } });
if (!.ok) throw new (.problem.title);
for await (const of shop.events("/exports/:id/events", { : { : .data.id } })) {
if (.event === "progress") bar.set(.data.done / .data.total);
if (.event === "end") .(.data.state); // "done", "failed" or "canceled"
}In a browser, a plain EventSource does the same:
const events = new EventSource(`/exports/${id}/events`);
events.addEventListener("progress", (e) => show(JSON.parse(e.data)));
events.addEventListener("end", () => events.close()); // or it reconnectsAnd from a shell:
curl -X POST localhost:3000/exports -H 'content-type: application/json' \
-d '{"from":"2026-01-01","to":"2026-06-30"}'
curl -N localhost:3000/exports/q3Z0…/eventsThe result
curl 'localhost:3000/exports/q3Z0…/result?wait=10'The result as the result schema lists it. With wait, the request waits up to that many
seconds (30 at most) for the job to be over, which is often all a script needs instead of a
stream. A job not done yet is a 409 job-not-done with retry-after; a failed one a 409
job-failed with its error, a canceled one a 409 job-canceled. An app-wide timeout
does not cut the wait short.
Finished jobs are kept for retain milliseconds (an hour by default), then forgotten. DELETE on a finished job forgets
it at once (204).
Canceling
DELETE /exports/:id on a waiting job takes it out of the queue. On a running one it aborts
job.signal and answers 202; the job is canceled once the work stops. The work has to
listen: hand job.signal to fetch or the database driver, or check it in the loop.
Whatever it returns after the abort is thrown away.
Whose job it is
Ids are 128 random bits, but anyone with an id could read the result. owner ties a job to
whoever started it, and everybody else gets a 404 for it:
.job("/exports", { : , : () => .headers["x-user-id"] }, run);Read the owner from what your auth hook made sure of, a session or a verified token, not
from a header anyone can send. security on the job asks for credentials on all five routes.
On a schedule
.("/reports/nightly", { : "0 3 * * *", : }, async () => buildReport(.));
.(
"/exports",
{ : , : { : "0 6 * * mon-fri", : { : "2026-01-01", : "2026-12-31" }, : "local" } },
run,
);schedule starts the job on a schedule as a POST would: the same queue, concurrency and store,
without an owner, and the five routes are still there to look at it, follow it or cancel
it. job.ctx of a scheduled run is a context of no real request: a POST to the job's path,
no headers, its input as the body.
| field | takes |
|---|---|
| minute | 0-59 |
| hour | 0-23 |
| day of month | 1-31 |
| month | 1-12 or jan-dec |
| day of week | 0-7 or sun-sat (0 and 7 are Sunday) |
Each field takes *, a value, a range (1-5), a step (*/15, 10-50/20, or 5/15 for
from 5 to the end) and lists of these (1,15,30-31). As in Vixie cron, when both the day of
month and the day of week are restricted, a day matches either one: 0 0 1 * mon is the
first of the month and every Monday. @yearly, @monthly, @weekly, @daily,
@midnight and @hourly stand for what they say.
- Time zone. UTC by default;
timezone: "local"reads the cron in the process's own zone. - Checked when the job is defined. A wrong expression, a cron that never matches, another
zone, a path with parameters, or an
inputthat breaks thebody(a job with abodyneeds one) stops the start with an error. - No overlap. A run is skipped while the last one the schedule started still waits or runs.
- When it runs. Schedules start once the app listens (or on
app.started()for an adapter) and stop on shutdown. Timers are unref'd and set for the next minute each time, so they do not drift; waits past 24.8 days go in hops. - With
workersonly worker 1 runs the schedules, and a worker that replaces it takes its place: a run starts once, not once per process.
Options
| option | default | |
|---|---|---|
body, progress, result | schemas; the progress and the result are trimmed to them, and in development one that breaks its contract fails the job | |
concurrency | 1 | how many run at the same time, in this process |
queue | 100 | how many may wait; past that, starting one is a 503 job-queue-full |
retain | 3600000 | milliseconds a finished job is kept for its result |
timeout | none | milliseconds a job may run; past them it fails with a 504 problem and job.signal is aborted |
owner | (ctx) => string | undefined: whose job it is | |
store | one memoryStore() for the app, 1000 jobs | any Store: where jobs are kept (below) |
schedule | a cron expression, or { cron, input, timezone }: start it on a schedule (above) | |
examples | examples for starting one; the other four routes get examples built on the first | |
summary, description, tags, deprecated, hidden, security, name | as for any route; name names the job schema |
Honest limits
- Jobs run in the process that started them. One still running when the process ends is lost. There are no retries and nothing is written to disk. Work that must not get lost belongs in a real queue (a database table, Redis, a broker) with a worker of its own.
- On SIGINT or SIGTERM nothing new starts, waiting jobs are canceled, event streams end, and running jobs get a few seconds to finish before their signal is aborted.
- With
workers,listen()refuses jobs kept in memory: a request for a job could land on a process that never heard of it. Run one process, or giveapp.joba store the processes share (shared: true), such as Redis or a database. - Not on serverless. A function may be frozen or gone the moment its answer is out. In development inkan warns on Lambda, Vercel and Netlify.
Where jobs are kept
import { memoryStore } from "@vxnsin/inkan";
import { redisStore } from "@inkanjs/redis";
app.job("/exports", { store: memoryStore({ max: 5000 }) }, run); // this process
app.job("/exports", { store: redisStore(redis) }, run); // every process and serverstore takes a Store, the one interface inkan keeps state in. The store
only keeps what a job looks like; the work always runs where it was started.
- One key per job. It is written as the job moves, and forgotten by the store's own TTL
retainmilliseconds after the job ends. Writes of one job are coalesced, the latest wins, so a job that reports progress in a tight loop does not hammer the store. - Across processes. On a store with
shared: true, the job's progress and a cancel reach the other processes through the store'spublish/subscribe: aGET …/eventsor aDELETEcan land on any worker. A shared store without publish/subscribe works too; a process following a job another one runs then reads it once a second. - Times are numbers. A
JobRecordkeepscreatedAt,startedAtandfinishedAtas epoch milliseconds, since records go through JSON. The job routes still answer with dates. - A store that fails is reported to
onError; the job goes on.
Without a store, the app keeps its jobs in one memoryStore() of 1000 keys, the one used
longest ago going first.