Hologram v0.12: Faster Compiles, Smaller Bundles, and More
More and more projects run Hologram in production now, and the apps keep getting bigger. The bigger ones ran into something small apps never notice. The compiler's cost grew with the app, and it paid that cost again on every run. On a very large app that meant minutes of waiting and most of the machine's memory. This release fixes that.
A cold build takes seconds. A save reloads the tab you're looking at in a second or two. A mix command with nothing changed skips the Hologram compile altogether. And bundles got a lot smaller, because a struct type's code now loads only when a struct of that type reaches the browser.
It's not all performance though. There's prop validation, props you can read from an action, and actions that run in the order you dispatched them.
The App Behind the Numbers
Almost every number in this post comes from one codebase. Evo Store is Hologram's Performance Sponsor, and their app is very large. It puts over 9,000 Elixir modules in front of the compiler, with about 160 Hologram pages.
Hologram v0.11.1 couldn't build it. On a 16 GB machine the compile ran out of memory and got killed after about four minutes, so Evo Store had to run a temporary fork to get by.
Evo Store came on as Performance Sponsor to get that fixed properly. Oliver Mulelid-Tynes (@olivermt) gave me their real app to profile against, which beats any benchmark I could've written myself, and he reported a good part of what's fixed here, including the reproduction behind #938 and the analysis that pinned down #891. Thank you, Oliver!
The work was tracked in two issues: #1256 for compile time, memory and live reload, and #1282 for bundle size. Timings below are from an M1 Pro with 16 GB, and they cover Hologram's part of a compile, not Elixir's.
A Compiler That Doesn't Start From Scratch
Once the first fix was in, the app at least compiled. A cold build took 290 seconds and peaked at 12.7 GB on that 16 GB machine, so most of the time went to swap. A compile with nothing changed took 107 seconds.
I went looking for the one big bug and there wasn't one. The compiler was correct. It was just wasteful in about two dozen places, and none of them hurt until an app got big. A few of them:
- It started one task per module, per page and per entry file, with no limit. Capping that at the number of schedulers took the cold build from 290 seconds to 130 (#1217).
- It loaded every module into the VM just to ask whether it was a page, a component or a protocol. Now it reads that from the beam file, once, and again only when the file changes (#1222, #1223).
- It built IR for all 9,021 modules, though the pages reach only 1,443 of them (#1255).
- It encoded the same function to JavaScript once for every page that uses it. That came to 207,292 encodings for 3,810 distinct functions (#1194).
- And it threw most of its work away. The call graph was saved between compiles and patched, but the IR behind it, the function encodings and every bundle were rebuilt from scratch each time. All of that now survives between compiles, in the VM and on disk, and a compile only patches what the Elixir compiler says changed (#1219, #969).
What I didn't expect is how much the fixes compounded. Each one removed whatever was hiding the next, so the gains multiplied. Forty milliseconds here, fifty there, and it adds up. A compile with nothing to rebuild went from 107 seconds to 72, then 32, then 4, then 1.5, then 0.05. The bundle size work fed back into it too. Smaller page bundles leave esbuild less to do, and that alone took a layout edit's full rebuild from 14 seconds to under 5.
By the end it was pretty much a rewrite of the call graph analysis and the bundling pipeline. 645 commits went in since v0.11.1, and close to 40,000 lines.
Here's where that leaves the same app on v0.12:
- A cold build takes 23 seconds and peaks at 4.7 GB.
mix compilewith nothing changed takes about 1 second, Elixir's part included.- A dev server that's been through a few edits holds about 0.3 GB. It used to hold over 3 GB.
That middle one matters most for deploys. Outside dev and test, every mix command that needs the app compiled used to run the Hologram build again. Oliver's deploy job in #969 had five steps and ran the build four times, at roughly 3 minutes 20 seconds each. Now only the first one has anything to do.
Tracking issue #1256 - 25 issues, 24 PRs, 290 commits.
Live Reload Starts With the Tab You're Looking At
A save used to recompile and bundle every page of the app, then reload every connected tab. It didn't matter what you'd edited. You waited for all of it.
Now the compiler walks the call graph backwards, from the modules you edited to the pages that reach them, and rebuilds only those. Edit one page file in the 9,000-module app and 8 page bundles change, out of 158. The other 150 used to come out byte-identical and get rebuilt anyway.
Some edits still reach a lot of pages. The main layout is used by more than half of them. So the rebuild has an order. The server knows which page each tab is showing, because every tab holds a realtime stream. It builds the pages open in tabs first, then the pages those link to, then the rest in the background. A tab reloads as soon as its own page is ready, and a tab on a page the edit didn't touch doesn't reload at all. If you click through to a page that's still waiting for its rebuild, the request waits and that page jumps the queue.
On the 9,000-module app, after Elixir's own recompile:
- Edit a page, and the open tab's bundle is ready in 1.7 seconds. The first save after a boot takes 2.
- Edit the layout that 90 pages use, and the open tab waits the same 1.7 seconds. The other 89 pages are done 3.6 seconds in.
- Edit a module no page uses, and Hologram is done in under half a second.
Live reload events also moved to the SSE stream. That leaves the old WebSocket connection with almost nothing to do, and one step closer to being removed.
Issues #1241 and #1243, both part of #1256.
Struct Code Loads When a Struct Arrives
The compiler used to decide at build time which struct types a page could see. That was every type the page's client code names, plus every type init/3, its commands and its broadcasting code name. Then it shipped the protocol implementations and __struct__ functions for all of them. Most of those types never get anywhere near a browser.
Oliver's reproduction in #938 shows how bad that could get. A page built one Tempo value inside init/3, turned it into a string, and put only the string into state. That was enough to drag Tempo, Calendrical and Localize into the runtime bundle that every page loads. Minified, it went from 374 KB to 4.46 MB, or from about 80 KB to about 470 KB gzipped.
In v0.12 struct code isn't in the runtime bundle or in the page bundles. The compiler writes it to chunks, which are separate files per struct type, and the browser loads a type's chunks when a struct of that type actually reaches it:
def init(_params, component, _server) do
# Loads Price's chunks: the struct reaches the state.
price = %Price{amount: 1_000, currency: :EUR}
# Loads nothing: only the string reaches the state.
every = "Every #{Duration.to_iso8601(Duration.new!(minute: 30))}"
put_state(component, price: price, every: every)
end
Before the server sends a value to the browser, it checks which struct types are in it and tells the browser which chunks the value needs. That goes for the state init/3 sets, an action a command returns, and an action the server broadcasts. The browser remembers which chunks it already has, so each one is fetched once and every later struct of that type costs nothing. It doesn't matter where the struct came from, so one read from the database loads its chunks the same way as one built in init/3. A module atom counts too. Put type: User into state and struct(type, name: "Ada") works in an action. And a page preloads the chunks for the struct types its own client code names, since those structs never pass through the server.
Reflection functions work the same way now. An Ecto schema's __changeset__/0 and __schema__/1,2 ship with a page only when the type can reach the page and something on the client can call the function on a module it doesn't name.
Bundle sizes on the 9,000-module app, minified, in a dev build:
- The runtime bundle went from 5.6 MB to 660 KB. With client stacktraces off, the way you'd deploy, it's 584 KB, or 133 KB gzipped.
- Page bundles went from about 7 MB each to a median of 444 KB. With client stacktraces off, that's 381 KB, or 29 KB gzipped.
The runtime bundle is the framework itself plus the parts of Elixir's standard library it relies on. Every page loads it, and the browser caches it after the first one. A page bundle is the page's own code: its templates, its actions and whatever they call. Page bundles gzip much better because templates and compiled Elixir repeat the same few constructs over and over.
Tracking issue #1282. Issues #891, #938 and #1317 - 125 commits. Documented on the new Bundling page.
There's a Lot Left
Hologram can get a lot faster than this. There are 15 open performance issues as I write this, and they cover the compiler, live reload, bundle sizes, page load and the code that runs in the browser. Rendering has a tracking issue of its own (#850). Plenty of them are small. But small ones are what took a no-change compile from 107 seconds to 0.05, each fix uncovering the next. So expect Hologram to keep getting faster with every release.
Prop Validation
prop used to accept any option you gave it. A typo like defualt: compiled fine and got quietly ignored, and you found out when the default never showed up.
Now an option prop doesn't know is a compile error. param takes no options yet, so passing it one is a compile error as well. And there are two new options, both actually enforced:
prop :size, :atom, required: true
prop :variant, :atom, values: [:primary, :secondary], default: :primary
Where a template spells the answer out, the build fails and tells you which component and which template:
** (Hologram.CompileError) prop "size" of component MyApp.Card must be one of [:small, :large], got: :huge, in MyApp.HomePage's template
** (Hologram.CompileError) component MyApp.Card is missing required prop "size" in MyApp.HomePage's template
That covers a missing required prop, and any value the compiler can work out without running anything. So plain text and literals, including lists, tuples and maps built from them. Everything else gets checked while rendering and raises Hologram.PropError: a prop that arrives through a spread, a component picked by a dynamic tag, a prop sourced from context, an interpolated value. Both renderers enforce it, server and client, so a component added after page load behaves the same.
Requested by @sodapopcan. Issue #772. Documented in the Options section of the Components page.
Props You Can Read in an Action
Until now, init got props as an argument and actions didn't. So an action that needed a prop's value had two choices: pass it through every event binding as a param, or copy it into state in init. The copy is a trap. init runs once, so the copy never updates when the parent passes something new.
Props are on the component struct now:
prop :label, :string
def action(:announce, _params, component) do
put_state(component, :announced, component.props.label)
end
They follow whatever the parent passes, so an action always reads the current value. Defaults and context-sourced props are in there too, and so is cid. A page's params are its props, so a page action reads a URL param the same way, with component.props.id.
Issue #1053. Documented in the new Reading Props in Actions section of the Components page.
Actions Run One at a Time
@deviprsd found this one, and it's nasty. If any clause of a component's action/3 awaits a Task, the whole function compiles as async. An async function returns its result a microtask late. So an action read the component's state when it started, and Hologram saved the new state a moment after. The gap is tiny, so it usually took events fired from code in quick succession to land inside it. When they did, they all read the same state, and the last save won. Ten events gave you one update, without any error to tell you why.
Client actions used to wait in four different places depending on how they arrived, and none of those waited for the previous action to finish. v0.12 puts them all in one queue. A component's actions run one at a time, in dispatch order, and each one sees the state the previous one left.
The wait is per component and not per page. An action only ever writes its own component's state, so one that awaits a Task holds back later actions for that component and nothing else. It's the guarantee the BEAM gives you for messages: ordered between one sender and one receiver, no promises across processes. It also means a promise that never settles, say a permission prompt nobody answers, stalls one component and not the whole page.
Issue #1292. Fixes #1002. Documented in the new Execution Order section of the Actions page.
Realtime Streams on HTTP/2 and Bandit
Three fixes to the realtime stream. The test suite couldn't have caught any of them, because it ran plaintext HTTP/1.1 on Cowboy.
- The SSE response carried a
connection: keep-aliveheader, which HTTP/2 forbids. Chrome and Firefox let it slide. WebKit drops the stream, and since HTTP/2 puts every request to an origin on one connection, commands failed along with it. So realtime was broken on iPhone Safari over HTTPS and nowhere else. Reported by @olivermt, with the cause already traced to the header (#1262). - On Bandit, a closed tab stayed subscribed for 15 to 30 seconds, until a heartbeat write failed. Every broadcast in that window did work for a connection with nobody on the other end. The stream now stops as soon as the client goes away (#1267).
- A stream that died without closing never got noticed by the client. The tab kept showing the page and just stopped receiving broadcasts until someone reloaded it. The client now watches the server's heartbeat and reconnects when two in a row go missing (#1268).
The test apps run on Bandit now, since that's what Phoenix generates. Cowboy has a test app of its own. And the realtime feature tests also run over HTTPS in CI, which is the only way to get a browser to negotiate HTTP/2.
Bug Fixes
- A guard with a long
inlist produced a bundle browsers couldn't parse.x in [a, b, c, ...]expands to a chain oforelsecalls, the encoder nested one closure per operand, and a 600-entry list became a 600-deep expression.ex_cldrhas one over the 601 IANA time zone names, so any app with it in the dependency tree got a runtime bundle Firefox refused to load. Guard chains are encoded flat now. Reported by @janwirth, with a reproduction app and a proposed fix (#1290). and,orand a shortinraisedbadargin any function compiled as async.andalsoandorelsecompile to native JavaScript operators now. Reported by @deviprsd, again with a reproduction and a proposed fix (#1306).- Navigating away from a page with a
$resizebinding raisedinvalid action targeton the next page, the first time you visited it. The observer behind the binding outlived its page for as long as the next page's bundle took to load. A left page's listeners are now detached when the navigation starts. Reported by @Blatts12, with a reproduction (#1291).
Documentation
- A new Bundling page explains what goes into the runtime bundle, a page bundle and a chunk, and when struct types, reflection functions and dynamic components ship.
- Components gained the
requiredandvaluesoptions, a "When Violations Are Reported" section and a "Reading Props in Actions" section. - Actions gained an "Execution Order" section.
- A new Reporting Vulnerabilities page says how to report a security issue privately. It came with v0.11.1.
usage-rules.mdand thellms.txtfiles were updated throughout, so AI assistants working in a Hologram codebase get the new options and the bundling rules right.
Maintenance Release
v0.11.1 shipped on the 0.11 line between the two feature releases, and it was a big one for a patch release. 36 issues went into it: 27 bug fixes, 4 performance improvements, 2 enhancements and 3 documentation updates. All of it is carried into v0.12. The ones I'd point at first:
- The initial page load hydrates. It used to rebuild the entire DOM instead of hydrating the server-rendered markup. Reported by @jamauro (#926). The same rebuild caused a flash of unstyled content, reported by @absowoot (#932).
- The comment markers are gone. v0.11 fixed sibling DOM identity by wrapping every template block in HTML comments. v0.11.1 reconciles by keys instead, so served HTML and the live DOM hold only the nodes your templates describe (#1021).
- Navigation takes one round trip. A navigated page paints from a single response, sent as data, so client-side navigation is no longer slower than a full document load (#1026, #1068, #1069).
- Subscriptions work across server instances. They silently stopped working with more than one. Reported by @jamauro (#992).
- Actions stay on their own page. Every action is tied to the page it was dispatched from, so one scheduled before a navigation can't run on the next page (#1047). The bug that started it was reported by @deviprsd (#1006).
- A serialized
</script>no longer cuts the page's mount data short. Reported by @olivermt (#959). - There's a private way to report a vulnerability. The repository has a security policy now, and the website has a Reporting Vulnerabilities page to go with it. Prompted by a question from @mbuhot (#1112).
Twelve of those 27 bugs, and one docs gap, came from people who ran into them and took the time to write them up. Thank you!
- @jamauro: #926, #975, #992, #998, #1001, #1019
- @deviprsd: #986, #1004, #1005, #1006
- @absowoot: #932
- @olivermt: #959
- @Blatts12: #796, the docs gap about props shadowed by state (diagnosed by @patric-vinicios)
The rest of what went in: #980, #1024, #1028, #1029, #1032, #1045, #1046, #1067, #1074, #1075, #1076, #1100, #1106, #1130, #1140, #1146, #1148.
Sponsors
I'd like to thank our sponsors whose support makes sustained development possible:
- Main Sponsor: Curiosum - ongoing sponsorship along with business insight and adoption guidance, helping shape Hologram's roadmap based on real-world production needs
- Milestone Sponsor: Erlang Ecosystem Foundation - milestone-based stipend, helping fund key development goals
- Performance Sponsor: Evo Store - sponsorship aimed at compile times and bundle sizes, with Oliver Mulelid-Tynes (@olivermt) providing the app this release was measured on and a steady stream of reports
We're working with the EEF on extending the stipend into a second round. What makes that possible is companies earmarking money for Hologram through the Foundation. If your company could do that, this post has the details.
Thanks also to our GitHub sponsors:
- Innovation Partner: Sheharyar Naseer (@sheharyarn)
- Framework Visionaries: @absowoot, Robert UrbaĆczyk (@robertu), Moss Piglet
And to every other GitHub sponsor: thank you! Contributions of any size genuinely help keep Hologram going.
If you'd like to support Hologram's development, consider sponsoring the project.
Stay in the Loop
Subscribe to the Hologram newsletter for a monthly roundup of everything Hologram: new releases and features, a glance at what's coming next, ecosystem news and new libraries, and the discussions worth catching from the community and socials, all in one place. You can also join us on Discord, the main hub for questions, discussion, and announcements, or find every way to connect on the community page.
- Bart