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
- Academy: OTP Fundamentals β Task and supervision primitives
- Glossary: Task Module, Concurrency, Fault Tolerance, Backpressure, OSINT
Five seconds. Ten concurrent. Kill on timeout. Unordered. That is 90% of the concurrency you ever needed.