536 words, 3 min read

When you need to let users download multiple files as a ZIP in a Phoenix application, creating the archive upfront is often wasteful. It costs disk space, adds latency, and does not scale well for large files.

A better approach is to stream the ZIP archive directly to the browser while it's being generated. In Elixir, the zstream library makes this straightforward and production-ready.

This post shows how to stream a ZIP file from a Phoenix controller without creating temporary files.

The idea

The solution combines three pieces:

  1. Plug.Conn.send_chunked/2 to start a streaming HTTP response
  2. zstream to generate ZIP data incrementally
  3. File streams (File.stream!/3) to avoid loading files into memory

The browser starts downloading immediately, while the server produces ZIP chunks on the fly.

Adding the dependency

def deps do
  [
    {:zstream, "~> 0.6"}
  ]
end

Streaming a ZIP from a controller

defmodule MyAppWeb.DownloadController do
  use MyAppWeb, :controller

  def images(conn, _params) do
    files = [
      {"image1.jpg", "/data/images/image1.jpg"},
      {"image2.png", "/data/images/image2.png"}
    ]

    conn =
      conn
      |> put_resp_header("content-type", "application/zip")
      |> put_resp_header(
        "content-disposition",
        ~s(attachment; filename="images.zip")
      )
      |> send_chunked(200)

    zip_stream =
      Zstream.zip(
        Enum.map(files, fn {name, path} ->
          Zstream.entry(name, File.stream!(path, [], 64_000))
        end)
      )

    Enum.reduce_while(zip_stream, conn, fn chunk, conn ->
      case Plug.Conn.chunk(conn, chunk) do
        {:ok, conn} -> {:cont, conn}
        {:error, :closed} -> {:halt, conn}
      end
    end)
  end
end

What's happening here:

  • Each file is represented as a Zstream entry
  • The file contents are streamed from disk
  • Zstream.zip/1 produces a stream of ZIP binary chunks
  • Each chunk is forwarded to the client using Plug.Conn.chunk/2

At no point is the full ZIP or any full file loaded into memory.

Adding folders to the ZIP

ZIP archives don't require explicit directory entries. Just use paths in the entry name:

Zstream.entry("images/image1.jpg", File.stream!(path, [], 64_000))

Why this approach works well

  • No temporary ZIP files
  • Constant memory usage
  • Handles large files gracefully
  • The download starts immediately in the browser
  • Works with standard reverse proxies like Nginx or Caddy

Common pitfalls

  • Always call send_chunked/2 before streaming data
  • Handle {:error, :closed} when the client disconnects
  • Use reasonably large chunk sizes (32–128 KB)
  • Ensure file paths and names are trusted to avoid ZIP path traversal

Using this from LiveView

LiveView cannot stream responses itself. The usual pattern is to trigger a navigation to the controller action:

push_navigate(socket, to: ~p"/downloads/images")

The controller then handles the streaming ZIP download as shown above.

Closing thoughts

If you need to package many files into a ZIP download in Phoenix, zstream is a simple and robust solution. It fits naturally with Plug's chunked responses and keeps resource usage predictable, even under load.

For most applications, this is the cleanest way to offer ZIP downloads without touching the filesystem at all.