Next.js SaaS
How to Run Background Jobs and Reliable Webhooks in a Next.js SaaS
Design durable jobs, verified webhooks, retries, idempotency, deadlines, failure states, and observable background work around a Next.js SaaS.
A request is a poor place for work that may take minutes, depend on an unreliable provider, or need to survive a process restart. Durable state has to outlive the request.
Identify work that should leave the request
Move imports, exports, reports, media processing, bulk email, provider synchronization, and other slow work behind a durable job boundary. The request should validate intent, write the job, and return an identifier the interface can use to show progress.
Not every asynchronous task needs a separate service on day one. It does need durable state, an owner, an execution contract, and a recovery path. An unawaited promise in a Route Handler can disappear when the process ends and leaves no reliable record for the user.
Model the job lifecycle
Record a stable ID, type, owner, validated input reference, status, attempt count, next run time, result reference, and sanitized error. Use states such as queued, running, succeeded, failed, and canceled. A lease or heartbeat prevents two workers from silently owning the same attempt forever.
Store large payloads and outputs outside the queue record when appropriate. The job should reference durable objects and re-read current authorization or ownership before publishing a result.
type JobState =
| "queued"
| "running"
| "succeeded"
| "failed"
| "canceled";
type Job = {
id: string;
tenantId: string;
type: string;
state: JobState;
attempts: number;
runAfter: Date;
};Make execution idempotent
A worker can crash after an external action succeeds but before local success is recorded. Design each step so it can be repeated safely. Use unique business keys, provider idempotency keys, upserts, and recorded checkpoints instead of assuming exactly-once delivery.
Separate the job identity from an attempt. Retries should share the same business job while recording distinct attempt evidence. The interface can then show what happened without creating several indistinguishable exports or emails.
Bound retries and external calls
Set connection and response deadlines around providers. Retry transient timeouts, rate limits, and temporary failures with backoff and jitter. Do not retry invalid input, revoked authorization, or a permanent provider rejection indefinitely.
After the attempt budget is exhausted, move the job to a visible failed state and alert according to its importance. Preserve enough error context to act without logging credentials or private payloads.
- —Classify errors before retrying.
- —Cap attempts and total elapsed time.
- —Spread retries with jitter.
- —Expose manual retry only when the operation is safe to repeat.
Secure incoming webhooks
Receive provider events in a Route Handler, verify the signature against the raw body, reject invalid or stale requests according to the provider protocol, and record the event ID before doing expensive work. Return success quickly once the durable handoff is complete.
Use a unique constraint to deduplicate deliveries. Map the provider event to a local tenant or account through trusted metadata or stored provider IDs, not an arbitrary tenant field in the payload.
Give users honest progress
A submitted job is not a completed job. Return the job ID and show queued or running state in the application. Refresh with server navigation, polling, or a realtime channel that fits the product. Make failure and cancellation visible rather than leaving a spinner forever.
Protect result downloads with the same ownership checks as the source request. Expire generated files when appropriate and separate user-facing explanations from internal error detail.
Test interruption and duplicate delivery
Stop a worker mid-step, restart it, deliver the same webhook twice, delay a provider beyond the timeout, exhaust retries, and remove a user's access before completion. Confirm that the operation remains safe and the final state is understandable.
Test deployment while jobs are running. Workers should finish, release, or safely retry leased work according to the design. Schema migrations must remain compatible with queued jobs created by the previous release.
Operate jobs and workflows beside the app
Adios can run persistent application processes and inspectable workflows with triggers, steps, waits, approvals, and run history. Keep the Next.js request path focused on validation and durable handoff, then let the worker or workflow own retryable execution.
Deploy configuration and secret references beside the source, inspect application and workflow logs separately, and use health checks for the services receiving traffic. This keeps the customer-facing release stable while background failures retain their own evidence and recovery path.
secrets:
DATABASE_URL: secret://DATABASE_URL
EMAIL_API_KEY: secret://EMAIL_API_KEY
runtime:
name: node@24
port: 3000
health_path: /api/health