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.

