The Error
You kicked off a long-running task with async and poll: 0, then used async_status to collect the result. Instead of a success, you get this:
FAILED! => {"msg": "Timeout (30s) waiting for async task to finish", "finished": 0, "started": 1}
"finished": 0 is the key detail — the task was still running when async_status gave up. It started fine. It just needed more time than you gave it.
Why This Happens
With poll: 0, Ansible fires the task and immediately moves on. No waiting. Later, async_status polls for the result by looping with retries and delay. The timeout fires when either of two things goes wrong:
- The
retries × delaybudget runs out before the task finishes. - The
asyncvalue itself is too small — Ansible kills the remote process after that many seconds.
Here's the default polling loop you see in most playbooks. Do the math: 10 retries × 3 seconds = 30 seconds total. That's fine for a quick script, but it won't survive a package install, database migration, or anything that actually takes time.
- async_status:
jid: "{{ async_result.ansible_job_id }}"
register: job_result
until: job_result.finished
retries: 10
delay: 3 # 10 × 3 = 30 seconds total wait
Step-by-Step Fix
Step 1 — Measure how long your task actually takes
SSH into the remote host and time it directly:
time apt-get install -y some-large-package
# or
time ./long_migration_script.sh
Take that number and add a 50–100% buffer. A task that runs in 3 minutes on a fast machine might take 6 minutes on an overloaded server at 2am. Budget for the worst case.
Step 2 — Set async high enough
async is the hard kill timer on the remote host. Once that many seconds pass, Ansible terminates the process — no matter how close it was to finishing. Set it well above your worst-case runtime:
- name: Run long database migration
command: /opt/app/migrate.sh
async: 600 # allow up to 10 minutes
poll: 0
register: migration_job
Step 3 — Fix the async_status polling loop
Bump retries and delay so their product matches (or exceeds) your async value:
- name: Wait for migration to complete
async_status:
jid: "{{ migration_job.ansible_job_id }}"
register: job_result
until: job_result.finished
retries: 60 # check up to 60 times
delay: 10 # every 10 seconds = 600 seconds total
The rule is simple: retries × delay must be ≥ async. If the polling loop gives up first, you get the timeout error even though the task is still running fine on the remote host.
Complete working example
---
- hosts: app_servers
tasks:
- name: Run package upgrade (long task)
apt:
upgrade: dist
async: 900 # 15-minute hard cap on remote
poll: 0
register: apt_job
- name: Wait for package upgrade
async_status:
jid: "{{ apt_job.ansible_job_id }}"
register: apt_result
until: apt_result.finished
retries: 90 # 90 × 10s = 900s — matches async value
delay: 10
- name: Show upgrade result
debug:
var: apt_result
Step 4 — Skip all this if you don't need fire-and-forget
Fire-and-forget with poll: 0 is only useful when you need to kick off multiple long tasks in parallel, or do other work while something slow runs. For a single long task, just drop poll: 0 and let Ansible handle the polling itself:
- name: Run long task with built-in polling
command: /opt/app/migrate.sh
async: 600
poll: 15 # Ansible checks every 15 seconds, no async_status needed
Fewer moving parts, same result.
Verify the Fix
Run with -v to watch the polling in real time:
ansible-playbook site.yml -v
You'll see lines like this while the task runs:
ASYNC POLL on host1: jid=..., started=1, finished=0
ASYNC POLL on host1: jid=..., started=1, finished=0
ASYNC OK on host1: jid=..., finished=1, rc=0
finished=1, rc=0 means success. Still timing out? Increase retries and rerun. If it's timing out immediately, double-check that your async value isn't the bottleneck.
You can also inspect the job directly on the remote host — Ansible writes async job files here:
ls ~/.ansible_async/
cat ~/.ansible_async/<jid>
Quick Reference: async vs poll vs async_status
- async: N — Hard kill timer on the remote host. The process dies after N seconds, done or not.
- poll: 0 — Fire and forget. You're responsible for collecting results with
async_status. - poll: N (N > 0) — Ansible polls automatically every N seconds. No
async_statusneeded. - retries × delay — Must be ≥
asyncor your polling loop will time out first.
Tips
- Only use
poll: 0when you actually need parallel execution — running multiple slow tasks simultaneously across hosts, or doing other work while waiting. Otherwise, built-in polling is cleaner. - Save the job ID right after launching the async task. If the play crashes before
async_statusruns, you can still check the job manually on the remote host using the jid from~/.ansible_async/. - Got
"failed": truefromasync_status? That's not a timeout — the task finished but returned a non-zero exit code. Checkjob_result.stderrfor the actual error. - Ansible Tower / AWX users: Tower has its own job timeout setting separate from the
asyncvalue. A task can be killed by Tower before the remoteasynctimer even triggers. Make sure both are configured with enough headroom.

