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:
Plug.Conn.send_chunked/2to start a streaming HTTP responsezstreamto generate ZIP data incrementally- 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
Zstreamentry - The file contents are streamed from disk
Zstream.zip/1produces 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/2before 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.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.