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"}
]
endRun 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
endStep 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.migrateEdit 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
endStep 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
endRegister 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
endNow 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
endmax_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
endStep 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
endTemplate 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
endRoute in router.ex:
scope "/blog", MyAppWeb do
pipe_through :browser
get "/:slug/og.png", PostController, :og_image
endIn 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 --dockerThis 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 deploySet 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