Async
Introduction
Pools can accept work asynchronously. The caller gets 202 Accepted with a
job id and poll URL. The job still competes for the pool's concurrency limit.
When the upstream finishes, nthbouncer posts a signed webhook and the poll
endpoint returns the result.
Async needs at least one active webhook destination. Enable async on Pools → Concurrency, then attach a destination from Webhooks. Sync proxy stays the default. Team and Business plans only.
Admission modes
| Mode | How it triggers | reason on poll / webhook |
|---|---|---|
| Header | X-Nthpool-Async: 1 on a normal pool request |
requested |
| On open wait | Request would wait for a slot → accept as a job instead of holding the connection | busy |
| On queue timeout | Open wait hits queue timeout → accept as a job instead of 504 queue_timeout |
wait_timeout |
All three share one job pipeline. Slot timing: the concurrency slot is held only while the upstream fetch runs (not while the job waits in the queue).
Async bypasses the shared response cache. Locks stay sync-only.
No active destination
A pool with async on but no active destination falls back to sync. That covers both cases: nothing attached yet, and every attached webhook deactivated after repeated delivery failures.
In that state the pool behaves exactly as it would with async off:
X-Nthpool-Async: 1returns the upstream response, not202.- On open wait and on queue timeout do not promote. A request waits for a slot
as usual and gets
429or504 queue_timeouton its own terms.
No jobs are created while a pool is in this state, so nothing sits unclaimed. Attach a working destination (or re-enable a deactivated one) and the next request goes back through the async path.
A pool that never enabled async still rejects X-Nthpool-Async: 1 with
400 async_disabled.
Accept response
{
"jobId": "job_…",
"status": "accepted",
"pollUrl": "https://api.nthpool.cloud/pools/your-pool/jobs/job_…",
"webhook": { "destinations": 2 }
}pollUrl is
https://api.nthpool.cloud/pools/{pool}/jobs/{jobId}, the same API host as
concurrency locks, not your pool hostname or a custom
domain. {pool} is your pool subdomain. Job ids are job_ followed by hex.
Database pools are the exception: their handles poll on the pool's own
hostname — see Database pools.
Poll
GET (or HEAD) the pollUrl with the same pool auth as a normal request or
a lock call. Poll is there for callers that cannot receive a webhook. When destinations are configured,
the webhook remains the primary completion signal.
Accept is 202 with status: "accepted". Poll always returns 200 for a
known job, with status set to the current lifecycle value:
accepted → queued → running → terminal completed | failed | expired
There is no job-level pending.
| Case | HTTP | Body |
|---|---|---|
| Unknown or wrong-pool job | 404 | { "error": "job_not_found" } |
Past expiresAt, still non-terminal |
200 | status: "expired" |
| Terminal job still in store | 200 | that terminal status |
Every poll body includes jobId, status, reason, createdAt,
updatedAt, and expiresAt. Terminal jobs may include result (upstream
status, headers, size-capped body). When destinations are on the job,
webhook reports destinations, delivered, and failed counts.
Retention
Job retention defaults to 86400 seconds (24 hours). Configurable per pool from 60 seconds to 604800 (7 days).
Webhooks
See Webhooks for destinations, signing, and retries.
Limitations
- On proxy pools, async requires at least one active webhook destination; without one the pool serves sync. Database pools return handles with async enabled alone.
- Max request body retained for a job defaults to 1 MiB (configurable per pool).
- Job retention defaults to 24 hours (configurable from 60 seconds to 7 days).
- Webhook delivery is at-least-once; receivers must be idempotent on
jobId. - Destination count per pool is plan-capped.