1 - How to: Author and manage Dapr Jobs using the JavaScript SDK
Learn how to schedule one-time and recurring jobs and handle their callbacks using JavaScript
Let’s register an endpoint to receive a job notification, then schedule the job from the same application. This guide uses DaprServer.jobs for callbacks and its embedded DaprClient.jobs for job management. There is no separate Jobs client package to install.
Prerequisites
In your application directory, initialize an npm project if needed and install the SDK:
npm init -y
npm install @dapr/dapr@3.19.0
The examples use JavaScript ES modules in a .mjs file, so no TypeScript compiler is required. The same APIs are available in TypeScript.
Register a handler and schedule a recurring job
Save the following as app.mjs:
import { CommunicationProtocolEnum, DaprServer } from "@dapr/dapr";
const server = new DaprServer({
serverHost: "127.0.0.1",
serverPort: "50002",
communicationProtocol: CommunicationProtocolEnum.GRPC,
clientOptions: {
daprHost: "127.0.0.1",
daprPort: "50001",
communicationProtocol: CommunicationProtocolEnum.GRPC,
},
});
const jobName = "daily-report";
try {
await server.jobs.listen(jobName, async (data) => {
console.log(`Received ${jobName}:`, data);
});
await server.start();
await server.client.jobs.schedule(jobName, {
schedule: "@every 10s",
repeats: 3,
data: { report: "daily", format: "csv" },
failurePolicy: { type: "constant", interval: "1s", maxRetries: 3 },
overwrite: true,
});
const job = await server.client.jobs.get(jobName);
console.log("Scheduled:", job.name, job.schedule, job.data);
console.log("Waiting for callbacks. Press Ctrl+C to stop.");
await new Promise((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
} finally {
try {
await server.stop();
} finally {
await server.client.stop();
}
}
Start the application and sidecar:
dapr run --app-id jobs-example --app-port 50002 --app-protocol grpc --dapr-grpc-port 50001 --dapr-http-port 3500 -- node app.mjs
The application listens on port 50002; its client sends management requests to the sidecar on port 50001. The job triggers three times at ten-second intervals. The callback receives the payload itself, not a job envelope or the job name. Capture the name in the handler’s closure if needed.
Register handlers before calling server.start(). Await all work in the handler; throw an error or reject its promise when processing fails so Dapr can apply the job’s failure policy. Delivery is at least once, so make handlers idempotent.
The example deliberately uses overwrite: true so it can be rerun while a job with the same name still exists. Without this option, scheduling an existing name fails. Overwriting replaces the existing job, including its timing and repeat settings; do not blindly reset a production schedule whenever a replica starts.
Jobs belong to the scheduling sidecar’s application ID and namespace. Callbacks go to that application, not to an arbitrary target application. Register the handler on every replica that can receive a callback. A handler can use other Dapr APIs to invoke another service or publish an event.
Stopping the Node.js server does not delete jobs stored in the Scheduler. The example also stops the embedded client explicitly because server.stop() only stops the callback server.
Use HTTP instead
For JSON payloads, you can use HTTP for both management requests and callbacks. Change the server’s communicationProtocol to CommunicationProtocolEnum.HTTP, and change its clientOptions to:
clientOptions: {
daprHost: "127.0.0.1",
daprPort: "3500",
communicationProtocol: CommunicationProtocolEnum.HTTP,
},
Run it with the matching app protocol:
dapr run --app-id jobs-example --app-port 50002 --app-protocol http --dapr-grpc-port 50001 --dapr-http-port 3500 -- node app.mjs
The SDK registers the HTTP callback route POST /job/:name; you do not need to implement this route yourself. Client and callback protocols can also differ. See the protocol guidance.
Schedule a one-time job
For a one-time job, supply dueTime without schedule:
await server.client.jobs.schedule("send-notification", {
dueTime: "30s",
data: { recipientId: "customer-123" },
});
To use an absolute time, pass an RFC3339 timestamp:
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
await server.client.jobs.schedule("send-notification-tomorrow", {
dueTime: tomorrow.toISOString(),
data: { recipientId: "customer-123" },
});
These snippets reuse the running server from the example. To receive these additional jobs, register a matching server.jobs.listen(...) handler for each name before starting the server. The remaining scheduling examples follow the same pattern.
Limit a recurring job’s lifetime
Combine schedule with dueTime to delay when a recurring job becomes active. Use repeats to limit the number of scheduled triggers, ttl to set expiration, or both:
await server.client.jobs.schedule("hourly-cleanup", {
schedule: "@every 1h",
dueTime: "10m",
repeats: 5,
ttl: "6h",
});
Omitting repeats leaves a recurring job running until it expires or is deleted. Relative dueTime and ttl values are calculated from job creation, not from the previous callback. See the option reference for accepted time formats.
For calendar-based scheduling, use a six-field cron string or a CronExpressionBuilder:
await server.client.jobs.schedule("morning-report", {
schedule: "CRON_TZ=America/New_York 0 30 9 * * 1-5",
data: { report: "daily" },
});
Retrieve or delete a job
Retrieve the stored configuration by name:
const job = await server.client.jobs.get("hourly-cleanup");
console.log(job.name, job.schedule, job.dueTime, job.ttl, job.repeats);
console.log(job.data, job.failurePolicy);
To stop future triggers, delete the job:
await server.client.jobs.delete("hourly-cleanup");
Deletion does not cancel callback work that is already running. It also does not unregister the application’s in-memory handler. To change a stored job instead, call schedule with its name, the replacement options, and overwrite: true.
Next steps
Read the client and callback usage guidance for payloads, retries, validation, and runtime compatibility, or the cron builder guide for fluent scheduling.
2 - Build cron schedules using the JavaScript SDK
Use raw cron expressions, descriptors, time zones, and CronExpressionBuilder to schedule Dapr Jobs
Use a cron schedule for calendar-based jobs, such as a report at 09:30 every weekday. The JavaScript SDK accepts a raw schedule string or a CronExpressionBuilder directly in JobOptions.schedule. These examples target @dapr/dapr 3.19.0 or later.
Provide a schedule string
Dapr cron expressions use six fields, including seconds, not the five fields used by many Unix cron tools:
second minute hour dayOfMonth month dayOfWeek
For example, "0 30 9 * * 1-5" runs at 09:30 on weekdays. Sunday is 0 and Saturday is 6.
await client.jobs.schedule("weekday-report", {
schedule: "0 30 9 * * 1-5",
data: { report: "daily" },
});
The snippet assumes a configured DaprClient. Register a matching handler in the receiving application as shown in the how-to guide.
You can also pass runtime-supported descriptors directly:
| Descriptor | Schedule |
|---|
@every 5m | A fixed five-minute interval. |
@hourly | The beginning of every hour. |
@daily or @midnight | Midnight each day. |
@weekly | Midnight on Sunday. |
@monthly | Midnight on the first day of each month. |
@yearly or @annually | Midnight on January 1. |
Use dueTime without schedule for a one-time job. The runtime validates raw schedule strings; the SDK does not parse them locally.
Use CronExpressionBuilder
The fluent builder follows the .NET SDK’s on, through, each, and every model, but uses string period names instead of .NET enums:
import { CronExpressionBuilder } from "@dapr/dapr";
const schedule = new CronExpressionBuilder()
.on("second", 0)
.on("minute", 30)
.on("hour", 9)
.through("dayOfWeek", 1, 5)
.withTimeZone("America/New_York");
console.log(schedule.toString());
// CRON_TZ=America/New_York 0 30 9 * * 1-5
await client.jobs.schedule("weekday-report", {
schedule,
data: { report: "daily" },
});
All fields initially contain *. An untouched builder produces "* * * * * *", which matches every second, not once per minute. Set the seconds field explicitly when you want minute-level or less frequent execution.
Periods and bounds
The exported TypeScript CronPeriod type accepts these names:
| Period | Integer values |
|---|
second | 0-59 |
minute | 0-59 |
hour | 0-23 |
dayOfMonth | 1-31 |
month | 1-12 |
dayOfWeek | 0-6, starting with Sunday |
Builder operations
| Method | Result |
|---|
on(period, ...values) | Select values, such as on("hour", 9, 17). Values are sorted and deduplicated. At least one is required. |
through(period, from, to) | Select an inclusive range, such as through("dayOfWeek", 1, 5). from must be less than to. |
each(period) | Reset a field to *. |
every(period, interval) | Set a step expression, such as every("minute", 15) producing */15. |
withTimeZone(timeZone) | Prefix the expression with CRON_TZ= using a validated IANA time zone. |
toString() | Return the six-field expression as a string, including its time zone if set. |
Each fluent method mutates and returns the same builder. A later call for a period replaces that field; it does not append another constraint. For example, on("hour", 9).on("hour", 17) selects only 17, while on("hour", 9, 17) selects both.
Values and range endpoints must be integers within the period’s bounds. A step must be a positive integer no larger than the number of values in that field: up to 60 for seconds or minutes, 24 for hours, 31 for days of the month, 12 for months, and 7 for days of the week. Invalid fields or values throw RangeError.
Steps are cron field steps, not elapsed-time intervals. For example, every("hour", 2) selects every second hour of the day; it does not mean two hours after registration. Use "@every 2h" for an elapsed interval.
toString() can be used independently of the Jobs client. Passing the builder to schedule takes its current string value; mutating the builder afterward does not update an already stored job. Reschedule with overwrite: true to change the stored schedule.
Specify a time zone
By default, Dapr evaluates a cron schedule in the Scheduler service’s local time zone, not necessarily the Node.js process’s time zone. Use an explicit IANA zone for a predictable wall-clock schedule:
const schedule = new CronExpressionBuilder()
.on("second", 0)
.on("minute", 0)
.on("hour", 9)
.withTimeZone("Europe/Rome");
The builder validates the time zone using Node.js Intl.DateTimeFormat. Empty, whitespace-containing, or unrecognized names are rejected. A subsequent withTimeZone call replaces the previous zone.
You can also pass the runtime’s time zone prefix in a raw string, for example "CRON_TZ=Europe/Rome 0 0 9 * * *" or "CRON_TZ=Europe/Rome @daily". Use "UTC" when daylight saving changes are not desired. With a regional zone, Dapr evaluates the local wall-clock schedule across daylight saving transitions.
Do not combine a time zone prefix with @every: fixed-duration intervals do not have a wall-clock time zone and the runtime rejects that combination. See the Jobs API reference for runtime schedule syntax.
3 - JavaScript Jobs client and callback usage
Configure the JavaScript Jobs client and understand scheduling options, payloads, failure policies, and errors
Jobs is part of DaprClient and DaprServer in @dapr/dapr 3.19.0 or later. Use client.jobs to manage scheduled jobs and server.jobs to register callbacks. The how-to guide contains a complete runnable application.
Client configuration and lifetime
Create a long-lived DaprClient rather than a new client for every operation. Jobs uses the client’s existing sidecar connection and API token:
import { CommunicationProtocolEnum, DaprClient } from "@dapr/dapr";
const client = new DaprClient({
daprHost: "127.0.0.1",
daprPort: "50001",
communicationProtocol: CommunicationProtocolEnum.GRPC,
daprApiToken: process.env.DAPR_API_TOKEN,
});
try {
await client.start();
await client.jobs.schedule("cleanup", { dueTime: "1h" });
const job = await client.jobs.get("cleanup");
console.log(job);
await client.jobs.delete("cleanup");
} finally {
await client.stop();
}
For HTTP, use CommunicationProtocolEnum.HTTP and the sidecar’s HTTP port, normally "3500". Client-only management does not require an app listener, but the target application must expose its callback server when the job fires.
When using DaprServer, configure its embedded client through clientOptions and reuse server.client.jobs. server.start() starts both the callback server and the embedded client. server.stop() stops only the server; also call server.client.stop() during application shutdown. Neither operation deletes stored jobs.
Client protocol and callback protocol
The two connections are configured independently:
| Connection | SDK setting | Dapr CLI setting |
|---|
| Application to sidecar | DaprClient.communicationProtocol, or DaprServer.clientOptions.communicationProtocol, with the corresponding daprPort | --dapr-http-port or --dapr-grpc-port |
| Sidecar to application | DaprServer.communicationProtocol and serverPort | Matching --app-protocol and --app-port |
JSON payloads support any combination of HTTP and gRPC. For arbitrary bytes, use gRPC on both connections. Selecting gRPC for the client alone does not change the sidecar’s callback protocol.
Runtime compatibility and API selection
Jobs requires Dapr runtime 1.16 or later and a running Scheduler service. Before making a Jobs request, the SDK reads the sidecar’s metadata to select the API:
| Runtime version | HTTP API prefix | gRPC management methods |
|---|
| 1.16 and 1.17 | v1.0-alpha1 | ScheduleJobAlpha1, GetJobAlpha1, DeleteJobAlpha1 |
| 1.18 prereleases | v1.0-alpha1 | Alpha1 methods |
| 1.18 stable and later releases | v1.0 | ScheduleJob, GetJob, DeleteJob |
Prereleases after 1.18 and the edge runtime identifier select the stable API. Both stable and Alpha1 gRPC callback services are registered by DaprServer.
The SDK caches successful API selection per client. Metadata lookup failures, missing or unrecognized versions, and unsupported runtime versions reject the operation rather than guessing an API. A subsequent call can retry a failed metadata lookup. A Jobs request that fails is not automatically replayed against a different API version.
The SDK package version and runtime version are separate requirements: using SDK 3.19.0 does not require runtime 1.19 merely to use Jobs.
Scheduling options
schedule(name, options) returns Promise<void>. Supply at least one of schedule or dueTime; both may be supplied together.
| Option | Type | Behavior |
|---|
schedule | string or CronExpressionBuilder | A six-field cron expression, a descriptor such as @daily, or an interval such as @every 5m. The builder is converted to a string. |
dueTime | string | When the job becomes active, or the trigger time for a one-time job without schedule. |
ttl | string | Job expiration. Relative values are calculated from job creation. |
repeats | number | Limits scheduled triggers. Omit for unlimited recurrence; zero is passed to the runtime, not converted to an omitted value. |
data | JSON value or Uint8Array | Optional callback payload. Node.js Buffer is also accepted by gRPC. |
overwrite | boolean | Defaults to false. Set to true to replace an existing job with the same name. |
failurePolicy | JobFailurePolicy | Controls retries when callback processing fails. Omit to use the runtime default. |
dueTime and ttl accept RFC3339 timestamps, Go duration strings such as "30s" or "1h30m", and non-repeating ISO8601 durations such as "PT30M". Pass strings, not JavaScript Date objects. For an absolute time, use date.toISOString() or an RFC3339 timestamp with an explicit offset.
The runtime validates raw cron expressions, time strings, and scheduling combinations. Jobs is a durable scheduler, not a precision timer: a trigger will not arrive before its due time, but may arrive later.
Names are case-sensitive. Pass a plain name, not a URL-encoded value; the HTTP client encodes the path segment.
The SDK rejects empty names, surrounding whitespace, . and .., control characters, and the characters /, \, ?, #, %, and |. Internal spaces are supported. These SDK restrictions also apply with a newer runtime that allows additional characters, such as |.
The SDK also validates:
schedule, dueTime, and ttl, when present, must be nonempty strings after converting a builder.repeats and failurePolicy.maxRetries must be integers between 0 and 4294967295.overwrite, when present, must be a boolean.- Payloads and failure policies must have supported values.
Invalid options reject the asynchronous operation before a Jobs request is sent. The cron builder performs additional local validation of its fields.
Retrieving and deleting jobs
get(name) returns Promise<Job> containing name and the optional schedule, dueTime, ttl, repeats, data, and failurePolicy properties. The SDK exports the related types for TypeScript callers:
import type { Job, JobFailurePolicy, JobOptions } from "@dapr/dapr";
const failurePolicy: JobFailurePolicy = {
type: "constant",
interval: "1s",
maxRetries: 3,
};
const options: JobOptions = { dueTime: "1h", failurePolicy };
await client.jobs.schedule("cleanup", options);
const job: Job = await client.jobs.get("cleanup");
console.log(job.name, job.failurePolicy);
JobOptions describes scheduling input, and Job describes retrieved configuration. overwrite is a scheduling option, not a returned job property. Returned timing strings use the runtime’s representation, which may differ from the input, and a builder’s schedule is returned as a string.
Retrieval returns configuration, not execution history, a next-run calculation, or a count of completed callbacks. A missing job causes get to reject, rather than returning undefined. delete(name) returns Promise<void>; deleting an already absent job is accepted by supported runtimes, while transport and service errors still propagate.
Payload serialization
JSON payloads
Both client transports and both callback protocols support JSON values: plain objects, arrays, strings, finite numbers, booleans, and null. Pass the value directly rather than manually JSON-stringifying it:
await client.jobs.schedule("create-report", {
dueTime: "30s",
data: { reportId: "report-123", includeArchived: false },
});
get(name).data and the callback argument expose the decoded value. Values such as false, 0, "", and null are preserved rather than treated as missing payloads.
The SDK rejects non-JSON data such as Date instances, BigInt, non-finite numbers, nested undefined values, and circular references. Convert such values to a JSON-compatible representation first.
Jobs uses its own payload encoding, not the client’s custom serializer. JSON payloads are packed as google.protobuf.Value for gRPC; the decoder also understands google.protobuf.Struct payloads from other clients. You do not need to construct protobuf envelopes yourself.
Omitting data is not identical across transports. HTTP scheduling sends JSON null for compatibility with older runtimes; gRPC leaves the payload absent. Do not rely on distinguishing an omitted payload from null across protocols.
Opaque binary payloads
Use gRPC to schedule and retrieve raw Uint8Array or Node.js Buffer payloads, including empty buffers:
const data = Buffer.from([0x00, 0x80, 0xff]);
await client.jobs.schedule("process-bytes", {
dueTime: "1h",
data,
});
const job = await client.jobs.get("process-bytes");
console.log(Buffer.isBuffer(job.data)); // true
For this example, configure client with gRPC. Use a gRPC DaprServer and --app-protocol grpc to receive the same bytes in a callback. Dapr’s HTTP app channel treats job callback bodies as JSON and is not suitable for arbitrary bytes.
HTTP scheduling rejects byte payloads rather than converting them to JSON. Opaque gRPC payloads are returned as Node.js buffers. The SDK uses the dapr.io/schedule/jobpayload type URL also used by the .NET Jobs SDK; it does not JSON-serialize or text-encode these bytes. For cross-language interoperability, agree on whether the payload is protobuf-wrapped JSON or raw bytes and decode accordingly.
Failure policies
The SDK accepts the following policies:
| Policy | Behavior |
|---|
| Omitted | Leave Dapr’s default policy in control. The runtime default retries up to three times with a one-second delay. |
{ type: "drop" } | Do not retry a failed delivery. |
{ type: "constant", interval: "1s", maxRetries: 3 } | Retry with a constant delay, up to the specified number of retries after the initial attempt. |
For a constant policy, omitting maxRetries allows unlimited retries. Setting maxRetries: 0 disables retries. Omitting interval, or setting it to "0s", requests immediate retries.
Unlike dueTime and ttl, the retry interval uses protobuf duration JSON syntax: seconds with an s suffix and up to nine fractional digits. Use "0.2s" for 200 milliseconds, "60s" for a minute, or "0.000000001s" for a nanosecond. Go-style strings such as "200ms" and "1m" are rejected here, as are negative durations.
await client.jobs.schedule("retryable-task", {
dueTime: "30s",
failurePolicy: {
type: "constant",
interval: "0.5s",
maxRetries: 2,
},
});
This policy permits the initial attempt plus two retries. repeats controls scheduled triggers; it is not a retry limit.
Callback behavior
server.jobs.listen(name, callback) returns Promise<void> and registers an asynchronous (data) => Promise<void> handler. Registration does not schedule a job. Register before starting the server, using exactly the same name as the scheduled job.
The SDK waits for the handler’s promise before acknowledging delivery. A resolved handler means success; its return value is not a response payload or retry instruction. Throw or reject to signal failure. Do not start unawaited background work and return early, or catch processing errors without propagating them if you want Dapr to retry.
Duplicate registration for a name rejects instead of replacing the existing handler. The SDK does not provide a handler removal API.
| Situation | HTTP callback response | gRPC callback response |
|---|
| Handler completes | 200 | Successful JobEventResponse |
| No matching handler | 404 | NotFound |
| Handler throws or rejects | 500 | Internal |
Missing handlers and failed handlers are logged and reported as errors, not acknowledged as successful deliveries. Design handlers for at-least-once delivery and use application-level deduplication when processing has non-idempotent side effects.
Error handling
All Jobs methods return promises. Await them so validation errors, metadata failures, sidecar errors, and Scheduler errors reach your application’s error handling.
Input validation can produce TypeError or RangeError; malformed runtime responses also reject. Remote failures propagate through the selected transport. HTTP failures include status and response details in the error message; gRPC failures use the gRPC transport’s error information. There is no Jobs-specific exception class shared across transports.
Do not assume an unsuccessful scheduling request proves that nothing was stored: a response can be lost after the server accepts a request. Reconcile by name before retrying, and choose overwrite deliberately. Failure policies govern callback delivery, not retries of client management requests.
These Jobs methods do not accept an AbortSignal, timeout argument, or cancellation token. Rejecting a local wait does not cancel a job; use delete(name) to remove its future schedule.