Task.async_stream Patterns
Engineering

Task.async_stream Patterns: The One OSINT Concurrency Primitive You Actually Need

You don't need a job queue for 50 parallel OSINT lookups. You need Task.async_stream with the right timeout, the right max_concurrency, and `on_timeout: :kill_task`. Here's the recipe and the three gotchas.

Apr 09, 2026 Β· 6 min read Β· TomΓ‘Ε‘ Korcak (korczis)

Most Elixir concurrency problems do not need Broadway, Oban, or GenStage. They need Task.async_stream. For the shape β€œfan out 50 things, collect results, don’t let one slow one hold up the rest”, there is no simpler primitive in the language. But it has three knobs that matter and one flag people always forget.

#The recipe

adapters
|> Task.async_stream(
  fn adapter -> adapter.search(query) end,
  max_concurrency: 10,
  timeout: 5_000,
  on_timeout: :kill_task,
  ordered: false
)
|> Enum.map(fn
  {:ok, {:ok, result}}   -> {:ok, result}
  {:ok, {:error, reason}} -> {:error, reason}
  {:exit, reason}         -> {:error, {:timeout_or_crash, reason}}
end)

#Knob 1: max_concurrency

Defaults to System.schedulers_online/0. That is almost never what you want. For IO-bound OSINT work, the right number is β€œhow many concurrent connections the slowest upstream can tolerate,” which is usually 5–20. For CPU-bound parsing, the default is fine. For mixed workloads, measure β€” do not guess.

#Knob 2: timeout

The per-task timeout. A slow adapter must not pause the whole stream. 5 seconds is a reasonable default for HTTP-backed adapters. Longer than 10 is almost certainly a design smell β€” if you need a 30-second budget, you probably want a job queue instead.

#Knob 3: on_timeout

This is the flag everyone forgets. The default is :exit, which means a timeout propagates as an exception into the caller. That is almost always wrong. :kill_task converts the timeout into a clean {:exit, :timeout} in the result stream, and the caller decides what to do. The fault tolerance you actually want is opt-in.

#Gotcha 1: ordered vs unordered

ordered: false lets results come back as they finish. This matters when one task is much slower than the others β€” unordered streams feel 10Γ— faster even though they do the same work. Use ordered: true only when the caller’s order matters (it usually doesn’t).

#Gotcha 2: linked supervision

Task.async_stream/3 links tasks to the caller. A crash in the caller kills everything. For long-running fan-outs, use Task.Supervisor.async_stream_nolink under a dedicated supervisor β€” crashes are isolated, the caller is safe.

#Gotcha 3: inside GenServers

Task.async_stream inside a handle_call blocks the GenServer until every task finishes. Do not do this. Either drive the fan-out from the caller or reply immediately and stream results back via PubSub.

#Where to go next

Five seconds. Ten concurrent. Kill on timeout. Unordered. That is 90% of the concurrency you ever needed.

Browse all β†’