Job queue + workflow control plane
Queue the work.
Keep your code.
A PostgreSQL-backed queue that persists, claims, retries, and observes background work. Your workers stay in your environment and execute the business logic.
- ● PostgreSQL durability
- ● Retry + DLQ
- ● SSE + WebSocket
Up and running in minutes
Enqueue over the API, run a worker in your environment, then subscribe to state changes. Official SDKs cover Node.js, Python, Go, and PHP.
Enqueue a job
POST a queue name and JSON payload. Pass an idempotency key and duplicate submissions collapse into one job. Bulk enqueue accepts up to 100 jobs per request.
Jobs & queues docs →import { SpooledClient } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
const userId = 'usr_123';
// Create a job
const { id } = await client.jobs.create({
queueName: 'email-notifications',
payload: {
to: 'user@example.com',
subject: 'Welcome!',
template: 'welcome',
},
idempotencyKey: `welcome-${userId}`,
maxRetries: 5,
});
console.log(`Created job: ${id}`);Run a worker
Workers run on your infrastructure. They claim jobs, run your code, and report complete or fail. Failures retry with exponential backoff — up to 100 attempts — then land in the dead-letter queue.
Worker docs →import { SpooledClient, SpooledWorker } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
const worker = new SpooledWorker(client, {
queueName: 'email-notifications',
concurrency: 10,
});
worker.process(async (ctx) => {
const { to, subject, body } = ctx.payload;
await sendEmail({ to, subject, body });
console.log(`Sent email to ${to}`);
return { sent: true };
});
await worker.start();Stream the results
Subscribe over Server-Sent Events or WebSocket instead of polling. The dashboard shows throughput, latency, failures, and DLQ replays from the same streams.
Real-time API docs →import { SpooledClient } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
// SSE realtime client (uses /api/v1/events with Authorization header under the hood)
const realtime = await client.realtime({ type: 'sse' });
realtime.on('job.created', (data) => {
console.log('job.created:', data);
});
await realtime.connect();
await realtime.subscribe({ queueName: 'orders' });See your queues live
The hosted dashboard subscribes to the very streams you saw fan out above — so what you watch here is exactly what your own clients receive, with no polling.
In the dashboard
Same streams, your clients
- Server-Sent Events One-way stream for dashboards and live views. Reconnects on its own.
- WebSocket Two-way channel when you need to send filters and commands back.
Watch jobs flow through the queue
Spooled stores every job in Postgres. Your workers claim and process them in parallel, failures auto-retry, and state changes stream to dashboards and clients. This simulation shows the lifecycle without pretending to be production telemetry.
Pipeline preview
DemoIllustrative states. Pro API allowance: 100 requests/sec (burst 200); completed and failed records are retained for 30 days.
Official SDKs: Node.js · Python · Go · PHP
Other marks show compatible HTTP sources and infrastructure, not native integrations.
- Stripe
- GitHub
- Shopify
- PostgreSQL
- Redis
- Node.js
- Python
- Go
- PHP
The safety net around every job
Failed jobs retry with exponential backoff. Exhausted jobs move to the dead-letter queue with payload and error history preserved. This shortened simulation shows inspect, fix, and replay; production jobs can be configured for up to 100 attempts.
job a1f9c… · queue payment-processing
Ready — press play to run the retry flow.
-
Initial attempt
t = 0sWorker claims the job and runs it
Failed · HTTP 500 - 1
Retry #1
t + 1mFirst backoff delay — 1 minute
Failed · Timeout - 2
Retry #2
t + 3mDelay doubles — 2 minutes
Failed · Connection refused - 3
Retry #3
t + 7mDelay doubles again — 4 minutes
Failed · HTTP 503 -
Dead-letter queue
exhaustedPayload and error history preserved for inspect & replay
Parked · inspect & replay
Backoff schedule
delay_minutes = min(2 ** retry_count, 60)
Retries are scheduled in minutes (1m, 2m, 4m …), doubling each time and capped at 60m, for up to 100 attempts. Exhausted jobs land in the dead-letter queue with their payload and error history intact — inspect and replay them from the dashboard or the API.
Recovery procedure
Inspect, fix, replay
Exhausted jobs keep their payload and error history. Recovery is explicit rather than silently dropping or endlessly cycling bad work.
- 01 / INSPECTRead payload + error historyIdentify whether failure came from data, code, or a dependency.
- 02 / FIXCorrect the underlying causeDeploy the worker fix or restore the unavailable service.
- 03 / REPLAYRetry one or many jobsReplay through the API or dashboard with traceability intact.
Keep workers fed without polling
gRPC bidirectional streaming keeps a worker and Spooled talking over one open HTTP/2 connection, so a single worker can process thousands of jobs per second — no per-job round trip, no polling.
See it in real code
Three patterns you can copy today. Each pairs the enqueue side with the worker side — the same durable queue sits between them. Pick a scenario, then expand any block to read the full snippet.
Live proof · SpriteForge ↗
One sprite is one workflow.
Frame generation fans out as parallel jobs. The assemble job waits on every frame, then the result streams back to the client.
- prompt
workflow - frames 1..N
parallel - dependency
join - assemble
result
Reliable Stripe checkout
Ack the Stripe webhook in milliseconds, then let a durable job fulfil the order — retried automatically if fulfilment fails.
Receive the event
Verify the signature, enqueue a job, return 200 fast.
import { SpooledClient } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
// Express.js webhook handler
app.post('/webhooks/stripe', async (req, res) => {
const sig = req.headers['stripe-signature'];
try {
// Verify and parse the webhook
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Queue in Spooled for reliable processing
await client.jobs.create({
queueName: 'stripe-events',
payload: event,
idempotencyKey: event.id,
});
// Return 200 immediately - Spooled handles retries
res.status(200).send('Queued');
} catch (error) {
res.status(400).send(`Webhook Error: ${error.message}`);
}
});Fulfil in a worker
Claim → do the work → complete or fail, on your own servers.
import { SpooledClient, SpooledWorker } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
const worker = new SpooledWorker(client, {
queueName: 'email-notifications',
concurrency: 10,
});
worker.process(async (ctx) => {
const { to, subject, body } = ctx.payload;
await sendEmail({ to, subject, body });
console.log(`Sent email to ${to}`);
return { sent: true };
});
await worker.start();Fan out a batch import
Turn one CSV upload into thousands of independent jobs — each row gets its own retry, idempotency key, and worker.
Enqueue per row
Read the file once, enqueue one job for every record.
import { SpooledClient } from '@spooled/sdk';
import fs from 'node:fs/promises';
const client = new SpooledClient({ apiKey: process.env.SPOOLED_API_KEY! });
const csv = await fs.readFile('users.csv', 'utf8');
const [headerLine, ...rows] = csv.trim().split(/\r?\n/);
const headers = headerLine.split(',').map((s) => s.trim());
for (const row of rows) {
if (!row.trim()) continue;
const values = row.split(',').map((s) => s.trim());
const payload: Record<string, string> = {};
headers.forEach((h, i) => (payload[h] = values[i] ?? ''));
await client.jobs.create({
queueName: 'csv-import',
payload,
idempotencyKey: payload.email ? `csv-${payload.email}` : undefined,
});
}
console.log('✅ Enqueued CSV jobs');Process each job
A minimal worker — swap the body for your real work.
import { SpooledClient, SpooledWorker } from '@spooled/sdk';
const client = new SpooledClient({ apiKey: process.env.SPOOLED_API_KEY! });
const worker = new SpooledWorker(client, {
queueName: 'my-queue',
concurrency: 1,
});
worker.process(async (ctx) => {
console.log('Job ID:', ctx.jobId);
console.log('Payload:', ctx.payload);
return { ok: true };
});
await worker.start();Push-to-deploy webhooks
A GitHub push enqueues a deploy job; a worker runs build, test, and release with retries instead of a flaky inline script.
On every push
Take the GitHub payload and enqueue a deploy job.
import { SpooledClient } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
// GitHub webhook handler
app.post('/webhooks/github', async (req, res) => {
const event = req.headers['x-github-event'];
const delivery = req.headers['x-github-delivery'];
// Queue for processing
await client.jobs.create({
queueName: 'github-events',
payload: {
event,
data: req.body,
},
idempotencyKey: delivery, // Use delivery ID for deduplication
});
res.status(200).send('Queued');
});Run the deploy
A worker claims the job and drives the pipeline.
import { SpooledClient, SpooledWorker } from '@spooled/sdk';
const client = new SpooledClient({
apiKey: process.env.SPOOLED_API_KEY!,
});
const worker = new SpooledWorker(client, {
queueName: 'email-notifications',
concurrency: 10,
});
worker.process(async (ctx) => {
const { to, subject, body } = ctx.payload;
await sendEmail({ to, subject, body });
console.log(`Sent email to ${to}`);
return { sent: true };
});
await worker.start();Division of labor
Spooled never runs your code. It stores jobs, schedules retries, and streams status; your workers do the work, wherever they already run.
Spooled handles
- Durable job storage in Postgres
- Retries with exponential backoff
- Deduplication via idempotency keys
- Dead-letter queues and replay
- Schedules, workflows, priorities
- Live status over SSE / WebSocket
You provide
- Workers — your code, your servers
- Email/SMS providers you already use
- Storage (S3, R2, GCS…)
- External APIs your jobs call
- The business logic itself
Your workers stay on your infrastructure and keep your secrets — Spooled only sees the job payloads you send it.
Rust core, Postgres durability, open end to end
The API and workers are Rust. PostgreSQL is the source of truth — every job, retry, and dead-letter entry is a row you could query, with every query scoped to your organization. Redis carries only real-time pub/sub for live dashboards and streams; losing it never loses a job.
The core is Apache-2.0: backend, queue engine, and all four SDKs. Self-host it against your own Postgres with no platform limits, or use the hosted cloud and skip the operating. Same API either way.
How work flows through Spooled
Questions engineers ask
What is Spooled Cloud?
Is Spooled a job queue without Redis?
How do retries and the dead-letter queue work?
What delivery semantics should I design for?
Where does my worker code run?
How are job payloads stored?
Is Spooled open source? Can I self-host it?
Put one real workload on a durable queue.
Try hosted Spooled with 1,000 jobs a day, or run the Apache-2.0 core against your own PostgreSQL.
- No credit card required
- Free tier forever
- Open source — Apache-2.0