Task and Task.Supervisor
Run concurrent work safely with Task and Task.Supervisor. Covers async/await, async_stream, cancellation, timeouts, and supervision-friendly background work.
Task is the standard Elixir abstraction for one-off concurrent work. It is ideal when you want to run a computation in parallel and collect the result.
Task.Supervisor is the production companion: it gives explicit ownership, visibility, and safer spawning rules for task processes.
Task Basics: async + await
task = Task.async(fn ->
expensive_calculation()
end)
result = Task.await(task, 5_000)
This pattern is great for a small number of independent calls.
Running Multiple Tasks with Back-Pressure
For collections, prefer Task.async_stream/3:
urls
|> Task.async_stream(&fetch_url/1,
max_concurrency: 10,
timeout: 3_000,
on_timeout: :kill_task
)
|> Enum.map(fn
{:ok, body} -> {:ok, body}
{:exit, reason} -> {:error, reason}
end)
Why this is better than manual spawning:
- concurrency is bounded (
max_concurrency), - failure semantics are explicit,
- timeouts are built in.
Task.Supervisor in Applications
For tasks launched from long-running processes (controllers, GenServers, jobs), use a supervisor:
# in your supervision tree
{Task.Supervisor, name: MyApp.TaskSupervisor}
# spawn a supervised task
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
send_email(user)
end)
This avoids coupling task lifecycles to request processes unexpectedly.
Choosing the Right Tool
Use Task when:
- the caller needs a result,
- work is short and bounded,
- failure should propagate to caller.
Use Task.Supervisor when:
- you need explicit supervision and ownership,
- work can outlive the immediate caller,
- you want operational visibility into spawned jobs.
Error Handling and Timeouts
- Always set realistic timeouts.
- Decide whether timeout should fail-fast, retry, or degrade gracefully.
- Normalize
{:ok, value}and{:error, reason}output for callers.
def safe_fetch(id) do
task = Task.async(fn -> fetch_remote(id) end)
try do
{:ok, Task.await(task, 2_000)}
catch
:exit, {:timeout, _} ->
Task.shutdown(task, :brutal_kill)
{:error, :timeout}
end
end
# Python asyncio.gather
results = await asyncio.gather(
fetch_user(id1),
fetch_user(id2),
return_exceptions=True,
)
// JavaScript Promise.allSettled
const results = await Promise.allSettled([
fetchUser(id1),
fetchUser(id2),
]);
# Elixir Task.async_stream
ids
|> Task.async_stream(&fetch_user/1, max_concurrency: 8, timeout: 2_000)
|> Enum.to_list()
Common Mistakes
- Spawning unbounded tasks from large collections.
- Forgetting timeout and cancellation paths.
- Using fire-and-forget tasks for critical writes without retries or monitoring.
Exercise
Parallel API Aggregator
Create a module that queries three external endpoints concurrently and returns a merged response:
- Use
Task.async_stream/3withmax_concurrencyand timeout. - Convert partial failures into structured errors instead of crashing.
- Add a fallback for timed-out endpoints.
- Move background refresh work into
Task.Supervisor.start_child/2. - Add tests that simulate timeout and crash behavior.
With Task patterns in place, you are ready for production workflow topics in Practical Development.
Related Lessons
Key Takeaways
- Task is the simplest way to parallelize bounded units of work in Elixir
- Task.Supervisor gives you safer lifecycle control for spawned background tasks
- Timeouts, cancellation, and back-pressure are required for robust async code