Making Fava's Journal Load Faster
Fava is a powerful Web UI for Beancount to look at what’s in the ledger. However, it had a long-standing issue that, when you have a lot of entries, it takes a long time for journal page to load. I have been using Beancount for more than five years, and there are over 15k entries in my ledger. It takes several seconds for the journal page to show up.
Apparently I’m not the only one affected by this issue. There are people with way larger ledgers than mine. I recall seeing two kinds of suggestions in the past:
- Only open journal with an interval selected, like over a year.
- Separate ledger by year, and let Fava open only one year a time.
I found such suggestions hard to follow, and rather just avoid opening journal page altogether. But still I do open it occasionally, intentionally or not, and it bugged me every time I did so. And I decided to look into it and see what can be done to make it better.
Initial attempt
The first thing I did was to look at what’s making it slow. The initial investigation was done a long time ago as I started looking into it around 2022 when there were only ~4k entries in my ledger. What I recall is, I first looked at the network timing for the request of journal page. It takes a long time in the “Waiting” phase, and the page is fairly big. And when I profiled the Python code of Fava, majority of the time is spent in Jinja, the template engine, rendering the template of journal table.

There wasn’t any obvious low-hanging fruit in the template engine, so I figured it’s probably just generating too much content, and thus the first thing I tried was to reduce the amount of content generated.
I noticed that price entries are never shown in the journal. All the entries in the journal are hidden by default by this CSS rule:
.journal > li {
display: none;
}
and each type of entries are only shown when their corresponding toggle is on. There is no toggle for price entries, but they are still included in the generated content nonetheless. With that observation, my first change was to simply skip rendering any price entry in the template.
How much it helps depend on how many price entries one has. In my case, I have price automated fetched for ~20 commodities for each month, i.e. accumulating ~250 annually. It represents ~7% of all my entries. It’s not a huge amount, but it’s still something worth improving, especially given it’s a trivial one-line change.
Following the same train of thought, I raised a question about whether postings can be loaded on demand rather than included upfront in the initial rendering. It seems to me that postings are shown only when I click the indicator of a transaction, while they make up a significant portion of content rendered from the template (more than 60% for me). It feels quite wasteful to have it spend 60% of the time rendering something I rarely need. But it got some pushback, because there are probably some users who want postings to be shown all the time via enabling its toggle.
Pagination
As number of entries in my ledger grows, it gradually bothers me more and more. Emboldened by the presence of agentic AI development tools1, I tried to take another look at it, with a more future proofing approach.
One idea floating around was to paginate the journal so that it doesn’t need to be rendered altogether. What shows first can be rendered first with the remaining done later. And this was what I implemented.
Fava renders journal page from its journal.html template, which includes the skeleton from _layout.html and the main journal table from _journal_table.html. The _layout.html base template shows the skeleton of all the Fava pages when ?partial=true is not specified for that request. When switching to the journal page from another page, the browser actually requests /journal/?partial=true to obtain just the table.
The journal table has a structure of
<fava-journal>
<ol class="flex-table journal">
<li class="head">
<!-- table headers -->
</li>
{% for entry in entries %}
<li><!-- entry rendering --></li>
{% endfor %}
</ol>
</fava-journal>
<fava-journal> is a custom element handled by the FavaJournal class on the frontend.
Given this structure, my plan was:
- Do the pagination in
journal.htmltemplate. When the number of entries exceeds a per-page limit, it slices the entries and returns the first slice of them along with passing the total number of pages via an attribute on the<fava-journal>custom element. - Then in the
FavaJournalclass, if that attribute is present, concurrently fetch all the remaining pages, and sequentially append them to the list.
This plan worked well. It significantly shorten the initial page rendering, so that I can actually see my journal pages sooner. But there was one problem: the order.
Most reasonable people I imagine would expect the journal page to show the latest entries first, or at least, that’s my expectation and what Fava comes with by default. However, the input to the template lists entries in chronological order, that is, the earliest entries come first. But changing the order for pagination is not as easy as reversing the input data because of how sorting is implemented for the journal page.
Back to the template above, in the table headers section, there are elements like
<span class="datecell" data-sort="num" data-sort-name="date">Date</span>
<span class="flag" data-sort="string" data-sort-name="flag">F</span>
and in each item there are
<span class="datecell" data-sort-value="{{ loop.index }}">{{ entry.date }}</span>
<span class="flag">{{ entry.flag }}</span>
What the sorting code does is, it parses data-sort and data-sort-name on header, then sort items in the list based on value in data-sort-value or, if unavailable, the text content of the corresponding element within each item. So far, Fava has been using the loop index as the sort value for date field so that when sorted by date, it actually follows the internal entry order of Beancount. But now, the sort value needs to take into account both pagination and the reversing.
Initially I implemented some smart way like using negative sort values. After discussing with the maintainer of Fava, we settled on generating an index for each item and use that to fill in this sort value to ensure the order is correct. 2
Flexbox
Another minor optimization I did was to make the journal table use display: flex. This was something I found while profiling the sorting code. With the default display: block, Firefox seems to spend a significant amount of time resolving (text) lines even though all the children means to be just blocks. Switching to display: flex in this case significantly reduces the time spent on updating the DOM as flexbox doesn’t involve lines.
This optimization, however, doesn’t seem to affect Chrome. There might be some optimization opportunities that Firefox could take for its block layout algorithm.
Conclusion
Now that the journal page loads much faster. Even when the ledger has a larger number of entries, the page shows up immediately. It may actually take more time in total for the page to load under the hood, but it has a much better perceived performance, and can be interacted with sooner. It also preserves the characteristic that all content are still in the page (after loading finishes), so that if user wants to do in-page search or scroll to a position to quickly jump to some time before, they can still do so quickly.
In long term, the maintainer stated that they consider the direction to be moving the rendering to the frontend, and potentially using virtual list. It sounds like a reasonable approach. At least it would significantly reduce the amount of data passed between the backend and the browser, and potentially make features like sorting easier and less error-prone to deal with. It is also likely to be more performant, both because
- rendering can be done more adaptively to local settings (e.g. not rendering hidden entries, not rendering postings by default, rendering in the right order directly), and
- in general, there have been way more resources put on JavaScript and its frameworks to make them run faster than for Python.
There is a draft PR made by someone else looking into moving the journal rendering into client side. It is a large change, but hopefully it also brings large benefits. Journal page seems to be the only major page still rendered on the server side.
But for now, the pagination is probably an okay middle ground that makes the page faster with minimal changes.