Why We Built DexcyJet on Elixir and Phoenix — An Email Platform Engineering Perspective | DexcyJet Blog

Why We Built DexcyJet on Elixir and Phoenix — An Email Platform Engineering Perspective

The technical case for Elixir and Phoenix as an email platform foundation — concurrency via the BEAM, fault tolerance, real-time LiveView, and the specific architecture choices DexcyJet made.

AR

Aakash Rao

Founding Engineer · March 20, 2026 · 9 min read

Why We Built DexcyJet on Elixir and Phoenix — An Email Platform Engineering Perspective

When we decided to build DexcyJet, choosing Elixir and Phoenix as the foundation wasn’t a contrarian bet — it was the result of mapping the specific technical demands of an email platform against what different runtimes actually deliver. This post explains that reasoning from first principles, covers the specific architecture choices we made, and is honest about the trade-offs.

The Specific Technical Demands of Email Infrastructure

An email marketing platform has a unusual combination of workload types:

High-concurrency async I/O: Sending a campaign to 250,000 subscribers means making 250,000 SMTP handshakes across 10+ delivery providers. Each of these is an I/O-bound operation — waiting for network responses, handling throttling, managing retries. You need to be doing thousands of these simultaneously.

Stream processing at high throughput: Tracking pixel fires and click redirect requests arrive in bursts. A campaign to 100,000 subscribers with a 35% open rate means 35,000 HTTP requests in the first hour. Each one needs to be logged, deduplicated, and aggregated.

Fault-tolerant job processing: Sending operations need to survive application restarts, delivery provider outages, and transient errors without losing state or sending duplicates. Jobs that fail need to retry with proper backoff, not disappear silently.

Real-time UI: Campaign senders expect to watch their send progress in real time — opens, clicks, bounces accumulating as the campaign goes out. This requires a persistent connection from the browser to the server.

Multi-tenant isolation: Multiple organisations’ subscriber data needs to be safely isolated. One customer’s campaign processing shouldn’t affect another’s.

Elixir and the BEAM runtime are unusually well-suited to this combination. Let me explain why.

The BEAM: Concurrency as a First-Class Primitive

The BEAM (Bogdan/Björn’s Erlang Abstract Machine) was designed in the 1980s to run telecommunications switches — systems that handle hundreds of thousands of simultaneous calls, cannot go down, and need to handle faults gracefully without affecting the rest of the system.

Lightweight processes

In Elixir, a “process” is not an OS thread. It’s a BEAM-level lightweight process — a unit of concurrency with its own heap, message box, and scheduling. You can run 100,000 processes simultaneously on a modern server with 16 cores. Each process is ~2KB of memory. Compare this to OS threads, which are 2–8MB each — the difference is 1,000x.

For email sending, this means: each in-flight SMTP connection can be its own process. 10,000 simultaneous SMTP handshakes is 10,000 BEAM processes — totally manageable. The equivalent in a thread-per-connection model would require 10,000 OS threads, which is several GB of memory and constant context-switching overhead.

# Sending 10,000 emails concurrently in Elixir
# Each Task.async spawns a separate BEAM process
subscribers
|> Enum.map(fn subscriber ->
  Task.async(fn -> DexcyJet.Delivery.send_to(subscriber, message, provider) end)
end)
|> Task.await_many(timeout: 30_000)

This is idiomatic Elixir. Each delivery runs in isolation — if one SMTP connection hangs, it times out independently without blocking any other delivery.

Preemptive scheduling

The BEAM scheduler uses preemptive reduction counting, not cooperative yielding. A long-running computation is automatically preempted after a fixed number of reductions (basic operations), giving other processes a turn. This means your web request handling never gets starved by a background processing job, even under high load.

Broadway: High-Throughput Stream Processing

DexcyJet uses Broadway for processing tracking events and delivery status updates from provider webhooks.

Broadway is a concurrent, multi-stage data ingestion pipeline. It handles:

  • Rate limiting: Automatically throttles processing to match your downstream database write capacity
  • Batching: Groups individual events into bulk database inserts for efficiency
  • Backpressure: Won’t pull more messages from the queue than it can process
  • Fault isolation: Failed processors don’t crash the pipeline
defmodule DexcyJet.TrackingPipeline do
  use Broadway

  def start_link(_opts) do
    Broadway.start_link(__MODULE__,
      name: __MODULE__,
      producer: [
        module: {BroadwayRabbitMQ.Producer, [
          queue: "tracking_events",
          connection: [host: "localhost"]
        ]},
        concurrency: 2
      ],
      processors: [
        default: [concurrency: 50, max_demand: 100]
      ],
      batchers: [
        default: [
          batch_size: 500,
          batch_timeout: 1_000,
          concurrency: 5
        ]
      ]
    )
  end

  def handle_message(_, %Broadway.Message{data: data} = message, _) do
    event = Jason.decode!(data)
    # Enrich and validate the event
    Broadway.Message.put_data(message, event)
  end

  def handle_batch(:default, messages, _batch_info, _context) do
    events = Enum.map(messages, & &1.data)
    # Bulk insert 500 events at once
    DexcyJet.Repo.insert_all(DexcyJet.Events.TrackingEvent, events)
    messages
  end
end

A single Broadway pipeline instance can process 50,000+ events per second — more than sufficient for a tracking infrastructure that targets sub-30ms p99 latency on pixel fires.

Oban: Fault-Tolerant Job Processing

DexcyJet uses Oban for all background job processing — campaign sends, warmup scheduling, bounce processing, webhook delivery.

Oban stores jobs in PostgreSQL (not Redis). This means:

  • Jobs survive application restarts (they’re in the database)
  • No separate job queue infrastructure to maintain
  • Jobs are transactional with your other database operations — a job is only created if its associated database transaction commits
  • Full visibility into job state and history via SQL queries
# Scheduling 250,000 individual send jobs for a campaign
def schedule_campaign_sends(campaign, subscribers) do
  subscribers
  |> Enum.map(fn subscriber ->
    %{
      campaign_id: campaign.id,
      subscriber_id: subscriber.id,
      org_id: campaign.org_id
    }
  end)
  |> Enum.chunk_every(1000)
  |> Enum.each(fn batch ->
    Oban.insert_all(Enum.map(batch, &DexcyJet.Workers.SendEmail.new/1))
  end)
end

Oban handles retries with exponential backoff, unique job constraints (preventing duplicate sends), and priority queues (transactional emails run on a high-priority queue ahead of bulk campaign emails).

Phoenix LiveView: Real-Time Without JavaScript Complexity

DexcyJet’s campaign dashboard uses Phoenix LiveView for real-time progress tracking. LiveView maintains a WebSocket connection between the browser and the server. When a tracking event arrives (open, click, bounce), the server pushes a diff to the browser without any JavaScript code on the client side.

defmodule DexcyJetWeb.CampaignLive.Show do
  use DexcyJetWeb, :live_view

  def mount(%{"id" => campaign_id}, _session, socket) do
    if connected?(socket), do: Phoenix.PubSub.subscribe(DexcyJet.PubSub, "campaign:#{campaign_id}")
    campaign = DexcyJet.Campaigns.get_campaign!(campaign_id)
    {:ok, assign(socket, :campaign, campaign)}
  end

  def handle_info({:tracking_event, event}, socket) do
    # Automatically pushes the update to the browser via WebSocket
    {:noreply, update(socket, :campaign, &DexcyJet.Campaigns.apply_event(&1, event))}
  end
end

No JavaScript event listeners. No REST polling. The browser displays real-time data because the server pushes incremental HTML diffs over the persistent WebSocket connection.

The Trade-offs We Accepted

Elixir/Phoenix is not the right choice for every project. The trade-offs we accepted:

Smaller talent pool: Python, Node, and PHP have dramatically more engineers available. Elixir specialists are fewer. For DexcyJet as a product team, this is manageable — we knew Elixir coming in. For a customer who wants to fork or self-host heavily modified code, it’s a consideration.

Library ecosystem: Python’s ML/data science ecosystem (pandas, scikit-learn) and Node’s npm ecosystem are vastly larger. Elixir’s ecosystem is smaller and more focused. For an email platform, this rarely matters — we don’t need pandas.

Learning curve: Functional programming and the actor model are different paradigms from OOP. New engineers need time to become productive.

Why we accepted them: The concurrency model, fault tolerance, and LiveView capabilities are so well-matched to the specific demands of email infrastructure that the trade-offs are worth it. We can achieve sub-30ms p99 tracking latency, 500k–1M emails/hour throughput per node, and real-time dashboards without operational heroics.

For the self-hosting story, see self-hosted email marketing in India. For the architecture at a higher level, see our features page.

Try DexcyJet: Built on Elixir/Phoenix, PostgreSQL, Oban, and Broadway — infrastructure-grade email marketing without the infrastructure management overhead. Start free.

Stay sharp on email deliverability.

Get new posts on email infrastructure, compliance, and engineering delivered directly. No spam — we eat our own cooking.

Try DexcyJet free →

Related posts

More on topics from this article.

technical engineering

Webhooks vs Polling for Email Events: Why Webhooks Win

Webhooks vs polling for email delivery events — the technical case for webhooks, HMAC-SHA256 signature verification, retry strategies, and the Elixir pattern DexcyJet uses internally.

Aakash Rao Mar 09, 2026 · 8 min