Tidbit

Minimal Periodic Task Runner in Elixir

November 18, 2025

Background

Recently, I needed a lightweight way to run a periodic job in the background to clean up some expired database records. Precise timing wasn’t important, this was the only background work needed in the app, and it needed to self heal if anything went wrong.

Because this was such a trivial task, I didn’t want to set up a heavyweight job execution framework like Oban. Additionally, this app was deployed as a single Docker container, so adding the clunkiness of cron jobs to the deploy process did not seem fun to me.

GenServer Timeouts

One of the cool features of the GenServer module is the :timeout message. From the docs:

The return value of init/1 or any of the handle_* callbacks may include a timeout value in milliseconds;

…when the specified number of milliseconds have elapsed with no message arriving, handle_info/2 is called with :timeout as the first argument.

This lets us initialize a GenServer with a timeout, store it as state, and return it from both init/1 and handle_info/2.

Now the GenServer will receive :timeout again after each run, which effectively triggers handle_info(:timeout, state) on the configured interval. This setup has the added bonus of preventing overlapping executions.

Here is a full example:

defmodule MyApp.ExpireWorker do
  use GenServer

  def start_link(period_in_milliseconds) do
    GenServer.start_link(__MODULE__, period_in_milliseconds, name: __MODULE__)
  end

  def init(period_in_milliseconds) do
    # {:ok, state, timeout()}
    {:ok, period_in_milliseconds, period_in_milliseconds}
  end

  def handle_info(:timeout, period_in_milliseconds) do
    case MyApp.Tokens.purged_expired() do
      {:ok, num_deleted} ->
        IO.inspect("Deleted #{num_deleted} expired tokens")

      {:error, error} ->
        IO.inspect("Error deleting expired tokens: #{inspect(error)}")
    end

    # {:noreply, state, timeout()}
    {:noreply, period_in_milliseconds, period_in_milliseconds}
  end
end

Usage

If you already have a supervisor, simply add it as one of the child processes with a timeout in milliseconds:

def start(_type, _args) do
  children = [
    MyApp.Telemetry,
    MyApp.Repo,
    {MyApp.ExpireWorker, 1000 * 60 * 5}, 
    MyApp.Endpoint
  ]

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

Otherwise manually start:

{:ok, pid} = GenServer.start_link(MyApp.ExpireWorker, 1000 * 60 * 5)

Alternatives

Another option is :timer.send_interval/2, but the :timeout mechanism keeps everything in a supervised GenServer and avoids overlapping executions.

The :timeout pattern doesn’t provide the stronger guarantees that a real job scheduler would, so something like Oban could be a better fit for tasks that aren’t lightweight cleanup tasks.