Stop blocking your supervisor in `init/1`

almirsarajcic

almirsarajcic

2 hours ago

0 comments

Every millisecond spent in init/1 is a millisecond your entire supervision tree is frozen. start_link/1 does not return until init/1 does, and the supervisor starts children one at a time — so a cache that loads for 300 ms delays every sibling started after it, and pushes your whole application boot back by the same amount. handle_continue/2 moves that work off the critical path without giving up the guarantee that it runs before any client request.

defmodule PriceCache do
  use GenServer

  @impl GenServer
  def init(opts) do
    # ❌ start_link/1 blocks for the full duration of the load
    # {:ok, %{rates: load_rates(opts), source: opts[:source]}}

    # ✅ return immediately, then keep working
    {:ok, %{rates: %{}, source: opts[:source]}, {:continue, :load_rates}}
  end

  @impl GenServer
  def handle_continue(:load_rates, state) do
    {:noreply, %{state | rates: load_rates(state.source)}}
  end
end

The third element of the :ok tuple is a continue instruction. The process finishes initialising, start_link/1 returns, the supervisor moves on to the next child — and handle_continue/2 runs as the very next thing that process does.

The part that makes it safe

handle_continue/2 is not “run it later and hope”. It is guaranteed to execute before any other message in the mailbox, including messages sent by callers who already have your pid. A client that calls PriceCache.get("EUR") the instant start_link/1 returns will block in GenServer.call/3 until the continue finishes, then get fully loaded state. You get the fast boot without the half-initialised window.

Measured against a 300 ms load:

{blocking, _} = :timer.tc(fn -> Blocking.start_link([]) end)
{continued, _} = :timer.tc(fn -> NonBlocking.start_link([]) end)

IO.puts("init/1 blocking:   start_link took #{div(blocking, 1000)}ms")
IO.puts("handle_continue/2: start_link took #{div(continued, 1000)}ms")

# init/1 blocking:   start_link took 300ms
# handle_continue/2: start_link took 0ms

The work still takes 300 ms. It just no longer holds the supervisor hostage while it happens.

Chaining and failure

A continue can return another continue, which is how you stage a multi-step startup without a single monster callback:

@impl GenServer
def handle_continue(:connect, state) do
  {:noreply, %{state | conn: open_connection(state)}, {:continue, :subscribe}}
end

@impl GenServer
def handle_continue(:subscribe, state) do
  Phoenix.PubSub.subscribe(MyApp.PubSub, "rates")
  {:noreply, state}
end

Failure semantics stay intact. Crashing inside handle_continue/2 terminates the process and the supervisor restarts it exactly as it would for a crash in init/1 — the difference is that the restart no longer blocks the sibling that was waiting behind you.

One caveat worth knowing: handle_continue/2 runs after init/1 returns, so anything that must be true before the supervisor considers the child started — registering a name, claiming a resource other children depend on — still belongs in init/1. Move the slow, self-contained work; keep the ordering guarantees.

handle_continue/2 docs

Comments (0)

Sign in with GitHub to join the discussion