Use `Task.Supervisor` instead of bare `Task.async`

almirsarajcic

almirsarajcic

1 hour ago

Bare Task.async creates linked tasks that can crash your process or leave zombie tasks running. Task.Supervisor provides supervised tasks with automatic cleanup and proper error isolation.

# ❌ BARE TASK.ASYNC - Crashes propagate, no cleanup
defmodule DataFetcher do
  def fetch_all_data do
    tasks = [
      Task.async(fn -> fetch_users() end),
      Task.async(fn -> fetch_posts() end),
      Task.async(fn -> fetch_comments() end)
    ]

    # If fetch_posts() crashes, the entire process crashes!
    # Orphaned tasks may continue running in the background
    results = Task.await_many(tasks, 5_000)

    {:ok, results}
  end
end

# ✅ TASK.SUPERVISOR - Isolated tasks with automatic cleanup
defmodule DataFetcher do
  def fetch_all_data do
    tasks = [
      Task.Supervisor.async(MyApp.TaskSupervisor, fn -> fetch_users() end),
      Task.Supervisor.async(MyApp.TaskSupervisor, fn -> fetch_posts() end),
      Task.Supervisor.async(MyApp.TaskSupervisor, fn -> fetch_comments() end)
    ]

    # Crashes are isolated - other tasks continue
    # Failed tasks return {:exit, reason}
    results = Task.yield_many(tasks, 5_000)

    {:ok, results}
  end
end

The key difference: supervised tasks don’t crash their caller and are automatically cleaned up when they complete or fail.

Handling task failures gracefully

Task.yield_many/2 returns results without raising, letting you handle failures individually:

defmodule ReportGenerator do
  def generate_reports(user_ids) do
    tasks =
      Enum.map(user_ids, fn user_id ->
        Task.Supervisor.async(MyApp.TaskSupervisor, fn ->
          generate_user_report(user_id)
        end)
      end)

    tasks
    |> Task.yield_many(30_000)
    |> Enum.map(fn {task, result} ->
      case result do
        {:ok, report} ->
          report

        {:exit, reason} ->
          Logger.error("Task failed: #{inspect(reason)}")
          nil

        nil ->
          # Task timed out - kill it
          Task.shutdown(task, :brutal_kill)
          Logger.error("Task timed out")
          nil
      end
    end)
    |> Enum.reject(&is_nil/1)
  end
end

Starting a Task.Supervisor in your application

Add the supervisor to your application’s supervision tree:

defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      MyApp.Repo,
      MyAppWeb.Endpoint,
      {Task.Supervisor, name: MyApp.TaskSupervisor}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Now you can use MyApp.TaskSupervisor throughout your application.

Parallel API calls with error handling

When calling multiple external services, supervised tasks prevent one failure from killing everything:

defmodule WeatherAggregator do
  def fetch_weather(location) do
    apis = [
      {"OpenWeather", &OpenWeatherAPI.fetch/1},
      {"WeatherAPI", &WeatherAPI.fetch/1},
      {"AccuWeather", &AccuWeatherAPI.fetch/1}
    ]

    tasks =
      Enum.map(apis, fn {name, fetch_fn} ->
        task = Task.Supervisor.async(MyApp.TaskSupervisor, fn ->
          fetch_fn.(location)
        end)

        {name, task}
      end)

    Enum.map(tasks, fn {name, task} ->
      case Task.yield(task, 5_000) || Task.shutdown(task) do
        {:ok, result} ->
          {name, {:ok, result}}

        {:exit, reason} ->
          Logger.warn("#{name} API failed: #{inspect(reason)}")
          {name, {:error, :api_failed}}

        nil ->
          Logger.warn("#{name} API timed out")
          {name, {:error, :timeout}}
      end
    end)
  end
end

Fire-and-forget tasks with supervision

For background work that shouldn’t block, use Task.Supervisor.start_child/2:

defmodule Analytics do
  def track_event(event_data) do
    # Don't wait for completion, don't care about result
    Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
      send_to_analytics_service(event_data)
    end)

    :ok
  end

  def send_welcome_emails(user_ids) do
    Enum.each(user_ids, fn user_id ->
      Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
        user = Repo.get!(User, user_id)
        Mailer.send_welcome_email(user)
      end)
    end)

    :ok
  end
end

These tasks run independently and clean up automatically when done. If they crash, they don’t affect your process.

Task pools for rate limiting

Limit concurrent tasks by setting max_children on your supervisor:

defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      MyApp.Repo,
      MyAppWeb.Endpoint,
      {Task.Supervisor, name: MyApp.TaskSupervisor, max_children: 10}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

When you hit the limit, new tasks wait until a slot opens up, preventing resource exhaustion.

When to use Task.Supervisor

Always use for:

  • Parallel API calls or external service calls
  • Background work that can fail independently
  • CPU-intensive computations that might crash
  • Any task where you need graceful error handling
  • Fire-and-forget operations that shouldn’t block

Bare Task.async is only safe for:

  • Short, guaranteed-safe operations
  • Code you control completely with no failure cases
  • Truly nothing (use Task.Supervisor everywhere)

Use Task.Supervisor for all concurrent work in production applications. Your processes won’t crash from task failures, and cleanup happens automatically.