Menu
elixirphoenixtutorialscreenshot-apiotp

Phoenix + GenServer Screenshot Tutorial — Concurrent Captures with OTP

SnapSharp Team·April 26, 2026·4 min read

Elixir's actor model is a natural fit for screenshot pipelines. Each capture is an isolated piece of work that can fail without taking down anything else, retries are first-class via supervisors, and OTP gives you a free job queue if you need one. Phoenix on top adds LiveView for real-time UI updates as captures complete.

This tutorial wires it all together. You'll build a Phoenix app with a Sites context, a GenServer pool of capture workers, an Oban-backed job queue for retries, and a LiveView that updates the page as screenshots arrive.

Prerequisites

  • Elixir 1.16+ and Erlang/OTP 26+.
  • Phoenix 1.7+ (mix phx.new my_app).
  • A free SnapSharp API key from snapsharp.dev/sign-up.
  • Familiarity with GenServer, Mix, and Phoenix contexts.

Step 1: dependencies

mix.exs:

defp deps do
  [
    {:phoenix, "~> 1.7"},
    {:phoenix_live_view, "~> 0.20"},
    # Existing deps...
    {:tesla, "~> 1.8"},
    {:hackney, "~> 1.20"},
    {:jason, "~> 1.4"},
    {:oban, "~> 2.17"}
  ]
end

Run mix deps.get.

Add the API key to config/runtime.exs:

config :my_app, :snapsharp,
  api_key: System.fetch_env!("SNAPSHARP_API_KEY"),
  base_url: "https://api.snapsharp.dev/v1"

System.fetch_env! raises at boot if the env var is missing — better than runtime KeyError.

Step 2: a Tesla-based SnapSharp client

Tesla is Elixir's idiomatic HTTP client with middleware composition.

lib/my_app/snapsharp/client.ex:

defmodule MyApp.Snapsharp.Client do
  @moduledoc """
  HTTP client for SnapSharp screenshot and OG image APIs.
  """
  use Tesla, only: [:post], docs: false

  plug Tesla.Middleware.BaseUrl, base_url()
  plug Tesla.Middleware.JSON
  plug Tesla.Middleware.Logger, log_level: :debug
  plug Tesla.Middleware.Timeout, timeout: 120_000

  defp base_url, do: Application.fetch_env!(:my_app, :snapsharp)[:base_url]

  defp api_key, do: Application.fetch_env!(:my_app, :snapsharp)[:api_key]

  @doc """
  Capture a screenshot of the given URL.

  Returns `{:ok, binary}` on success, `{:error, reason}` on failure.
  """
  def screenshot(url, opts \\ %{}) do
    body =
      %{
        url: url,
        width: 1280,
        height: 720,
        format: "png",
        block_ads: true
      }
      |> Map.merge(opts)

    headers = [{"authorization", "Bearer #{api_key()}"}]

    case post("/screenshot", body, headers: headers, opts: [adapter: [response: :stream]]) do
      {:ok, %Tesla.Env{status: 200, body: body}} -> {:ok, body}
      {:ok, %Tesla.Env{status: status, body: body}} -> {:error, {:http_error, status, body}}
      {:error, reason} -> {:error, reason}
    end
  end

  def og_image(template, variables, opts \\ %{}) do
    body =
      %{
        template: template,
        variables: variables,
        width: 1200,
        height: 630
      }
      |> Map.merge(opts)

    headers = [{"authorization", "Bearer #{api_key()}"}]

    case post("/og-image", body, headers: headers) do
      {:ok, %Tesla.Env{status: 200, body: body}} -> {:ok, body}
      {:ok, %Tesla.Env{status: status, body: body}} -> {:error, {:http_error, status, body}}
      {:error, reason} -> {:error, reason}
    end
  end
end

Step 3: the Sites context and schema

mix phx.gen.context Sites Site sites url:string title:string status:string screenshot_path:string captured_at:naive_datetime
mix ecto.migrate

Edit lib/my_app/sites/site.ex to add validations:

defmodule MyApp.Sites.Site do
  use Ecto.Schema
  import Ecto.Changeset

  schema "sites" do
    field :url, :string
    field :title, :string
    field :status, :string, default: "pending"
    field :screenshot_path, :string
    field :captured_at, :naive_datetime
    timestamps()
  end

  def changeset(site, attrs) do
    site
    |> cast(attrs, [:url, :title, :status, :screenshot_path, :captured_at])
    |> validate_required([:url, :title])
    |> validate_format(:url, ~r/^https?:\/\//)
    |> unique_constraint(:url)
  end
end

Step 4: a GenServer-based capture worker pool

Pool of workers that own a Tesla connection each. Use :poolboy for connection pooling:

# mix.exs deps
{:poolboy, "~> 1.5"}

lib/my_app/snapsharp/worker.ex:

defmodule MyApp.Snapsharp.Worker do
  use GenServer
  alias MyApp.Snapsharp.Client

  def start_link(_opts), do: GenServer.start_link(__MODULE__, %{})

  @impl true
  def init(state), do: {:ok, state}

  def capture(url, opts \\ %{}) do
    :poolboy.transaction(:snapsharp_pool, fn worker ->
      GenServer.call(worker, {:capture, url, opts}, 120_000)
    end)
  end

  @impl true
  def handle_call({:capture, url, opts}, _from, state) do
    {:reply, Client.screenshot(url, opts), state}
  end
end

Register the pool in application.ex:

defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    pool_config = [
      name: {:local, :snapsharp_pool},
      worker_module: MyApp.Snapsharp.Worker,
      size: 10,
      max_overflow: 5
    ]

    children = [
      MyApp.Repo,
      MyAppWeb.Endpoint,
      :poolboy.child_spec(:snapsharp_pool, pool_config, []),
      {Oban, Application.fetch_env!(:my_app, Oban)}
    ]

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

Now anywhere in the codebase you can call MyApp.Snapsharp.Worker.capture(url) and it'll grab a worker from the pool. With size: 10, ten captures run in parallel; an 11th request waits.

Step 5: an Oban job for resilience

Oban is Elixir's database-backed job queue. It survives restarts, supports retries, and integrates with Phoenix tooling.

config/config.exs:

config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [screenshots: 5],
  plugins: [
    {Oban.Plugins.Pruner, max_age: 86_400}
  ]

lib/my_app/jobs/capture_screenshot.ex:

defmodule MyApp.Jobs.CaptureScreenshot do
  use Oban.Worker, queue: :screenshots, max_attempts: 5

  alias MyApp.Sites
  alias MyApp.Snapsharp.Worker

  @impl true
  def perform(%Oban.Job{args: %{"site_id" => site_id}}) do
    site = Sites.get_site!(site_id)

    Sites.update_site(site, %{status: "processing"})

    case Worker.capture(site.url, %{width: 1280, height: 720}) do
      {:ok, image_binary} ->
        path = "screenshots/site-#{site.id}.png"
        File.mkdir_p!(Path.dirname("priv/static/uploads/#{path}"))
        File.write!("priv/static/uploads/#{path}", image_binary)

        Sites.update_site(site, %{
          status: "ready",
          screenshot_path: "/uploads/#{path}",
          captured_at: NaiveDateTime.utc_now()
        })

        Phoenix.PubSub.broadcast(MyApp.PubSub, "sites", {:updated, site_id})
        :ok

      {:error, {:http_error, status, _body}} when status in [400, 401, 403, 404] ->
        Sites.update_site(site, %{status: "failed"})
        {:cancel, "permanent failure: #{status}"}

      {:error, reason} ->
        {:error, reason}
    end
  end
end

max_attempts: 5 with Oban's default exponential backoff retries transient failures. {:cancel, ...} tells Oban to stop retrying for permanent errors (4xx).

In the Sites context, enqueue on create:

def create_site(attrs) do
  %Site{}
  |> Site.changeset(attrs)
  |> Repo.insert()
  |> case do
    {:ok, site} ->
      MyApp.Jobs.CaptureScreenshot.new(%{site_id: site.id}) |> Oban.insert()
      {:ok, site}
    other ->
      other
  end
end

Step 6: LiveView with real-time updates

lib/my_app_web/live/site_live/show.ex:

defmodule MyAppWeb.SiteLive.Show do
  use MyAppWeb, :live_view

  alias MyApp.Sites

  @impl true
  def mount(%{"id" => id}, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(MyApp.PubSub, "sites")
    end

    site = Sites.get_site!(id)
    {:ok, assign(socket, site: site)}
  end

  @impl true
  def handle_info({:updated, site_id}, socket) do
    if site_id == socket.assigns.site.id do
      {:noreply, assign(socket, site: Sites.get_site!(site_id))}
    else
      {:noreply, socket}
    end
  end
end

Template show.html.heex:

<div>
  <h1><%= @site.title %></h1>
  <a href={@site.url} target="_blank"><%= @site.url %></a>

  <%= case @site.status do %>
    <% "ready" -> %>
      <img src={@site.screenshot_path} alt={@site.title} loading="lazy" />
    <% "failed" -> %>
      <p>Capture failed.</p>
      <.button phx-click="retry">Retry</.button>
    <% _ -> %>
      <p>Capturing screenshot...</p>
  <% end %>
</div>

When the Oban job completes, it broadcasts via PubSub. The LiveView is subscribed and re-renders the template with the new screenshot. No polling, no client-side JS, no manual WebSocket setup.

Step 7: dynamic OG images for blog posts

Add a controller action for OG images. lib/my_app_web/controllers/post_controller.ex:

defmodule MyAppWeb.PostController do
  use MyAppWeb, :controller
  alias MyApp.Snapsharp.Client

  def og_image(conn, %{"slug" => slug}) do
    post = MyApp.Blog.get_post_by_slug!(slug)

    case Client.og_image("blog-post", %{
           title: post.title,
           author: post.author,
           date: Date.to_string(post.published_at),
           tag: List.first(post.tags || [])
         }) do
      {:ok, image_binary} ->
        conn
        |> put_resp_content_type("image/png")
        |> put_resp_header("cache-control", "public, max-age=86400")
        |> send_resp(200, image_binary)

      {:error, _reason} ->
        send_resp(conn, 502, "OG generation failed")
    end
  end
end

Route in router.ex:

scope "/blog", MyAppWeb do
  pipe_through :browser
  get "/:slug/og.png", PostController, :og_image
end

In post HEEX template:

<.live_head>
  <meta property="og:title" content={@post.title} />
  <meta property="og:description" content={@post.excerpt} />
  <meta property="og:image" content={url(~p"/blog/#{@post.slug}/og.png")} />
  <meta property="og:image:width" content="1200" />
  <meta property="og:image:height" content="630" />
  <meta name="twitter:card" content="summary_large_image" />
</.live_head>

Step 8: production deployment to Fly.io

Generate a release config:

mix phx.gen.release --docker

This creates a Dockerfile and a release script. Fly templates are first-party for Phoenix.

fly launch --no-deploy
fly secrets set SNAPSHARP_API_KEY=sk_live_...
fly postgres create
fly deploy

Set the worker concurrency in the Dockerfile or via env vars. The default queues: [screenshots: 5] runs 5 concurrent jobs.

Common pitfalls

Pitfall 1: blocking the GenServer. A capture can take 10+ seconds. If you call GenServer.call with the default 5-second timeout, it crashes. The pool worker uses 120_000 (2 min) — match your slowest case.

Pitfall 2: pool size mismatch with rate limits. A pool size of 50 + a burst of requests sends 50 concurrent calls to SnapSharp. The free tier (5 req/min) caps that. Match pool size to your plan's rate limit.

Pitfall 3: forgetting Phoenix.PubSub.broadcast. Without it, LiveViews don't know jobs completed. Always broadcast after meaningful state changes.

Pitfall 4: storing binaries in the DB. Don't put screenshot binaries in Ecto. Use the file system, S3, or Cloudflare R2. Postgres is bad at megabyte-sized blobs.

Pitfall 5: missing supervision. Pool worker crashes should be transparent — :poolboy restarts them automatically. But if you write your own GenServer without putting it under a supervisor, a single crash takes down the whole feature.

Final code

Six files:

  • lib/my_app/snapsharp/client.ex — Tesla-based HTTP client.
  • lib/my_app/snapsharp/worker.ex — pooled GenServer.
  • lib/my_app/jobs/capture_screenshot.ex — Oban job.
  • lib/my_app/sites/site.ex — Ecto schema.
  • lib/my_app_web/live/site_live/show.ex — LiveView.
  • lib/my_app_web/controllers/post_controller.ex — OG controller.

Around 250 lines of integration code. The OTP supervision tree handles fault tolerance for free.

Conclusion

Phoenix + GenServer + Oban is a fault-tolerant pattern for screenshot pipelines. Pool workers isolate failures. Oban handles retries with database-backed durability. LiveView streams completion events to the browser without WebSocket plumbing. Pair it with Fly.io and you get an Elixir service that handles thousands of concurrent captures on a single small VM.

Next steps: explore the screenshot API reference, read the Rust + Axum tutorial, or look at the FastAPI background task pattern for a Python equivalent.


Related: Pricing · Webhooks for real-time notifications · Why headless Chrome crashes

Phoenix + GenServer Screenshot Tutorial — Concurrent Captures with OTP — SnapSharp Blog