The cost of one more feature

Update, 27 July 2026: I rewrote this post almost completely. The original argued the same thing, but almost entirely in the abstract. This version is built on the Transfer 1.0 release, which shipped after the original was published.

On 12 July I released version 1.0 of Transfer, a small Nextcloud app I took over maintaining. It pulls a file from a URL straight into your Nextcloud account, without the file ever passing through the machine you are sitting at.

The release added live progress, a settings page, support for Nextcloud 34, and one checkbox.

The checkbox took longer than everything else put together.

It lets you start a download immediately instead of waiting for the next cron cycle. The request existed before I took over, but it was also something I had wanted myself. Most files transfer within seconds, and making someone wait up to ten minutes for the queue to run felt like a missing part of the main interaction.

The visible change was tiny. The implementation was not.

An immediate transfer keeps a PHP web worker occupied while the file downloads. It needs to be cancellable so that one abandoned request can’t sit there forever. Cancellation needs to work when the user clicks a button, closes the dialog, navigates away, or closes the browser without warning.

The solution I shipped uses a heartbeat. The browser polls for progress, and the server cancels the transfer if the heartbeat disappears. Progress and cancellation state have to be shared between processes, so the feature requires Redis or Memcached. On an instance without a distributed cache, the checkbox is hidden. I wrote about that implementation in more detail in When the open tab is the contract.

Making the basic path work was only the start. Before release, I found that failed transfers could leave the dialog stuck on “Starting” because a notification API had disappeared in newer Nextcloud versions. Cancellation was being reported as a server error. A transfer ID needed an ownership check before it could remove stored parameters. Long transfers disappeared from the settings page because the cache entry expired too early. Closing the dialog could accidentally cancel work. One Nextcloud 34 change caused successful activity entries to be displayed as malformed.

None of this was visible in the checkbox.

That is the cost I tend to miss when I first picture a feature. I see the control, the new code path, and the happy case. I don’t immediately see every existing assumption the new path has to join.

Everything it has to agree with

The immediate-download option had to work with the cron queue it bypassed, the progress page, cancellation, failure reporting, the distributed cache, the dialog lifecycle, Nextcloud’s security checks, and six supported Nextcloud versions. The new path had to produce the same outcomes as the old one.

The amount of code may still grow at a manageable rate, while keeping the surrounding behavior consistent gets steadily harder.

A new option might need to respect an existing permission check. A new background task might need to appear in an activity log. A new button might need disabled, loading, success, cancellation, retry, and error states. A new way to save something might need to update every place that assumes there is only one way to save it.

Not every feature interacts with every other feature. Some stay almost completely isolated. Others touch a shared path and immediately become part of a much larger system.

That is one reason mature software can become harder to change even when the code isn’t obviously bad. More things have become true, and future changes have to avoid making any of them false.

My direct experience here is still limited. I maintain small projects, not Firefox, Windows, or Kubernetes. Much of what I know about software growing beyond its original shape comes second-hand from bug reports, postmortems, maintainer discussions, and years of watching products change.

That has still affected how I work. I would rather become cautious too early than discover the same problems only after people depend on every accidental behavior I shipped.

Speed gets spent

Features also consume responsiveness. Sometimes the cost is direct. A new query runs when the application opens. Another condition is checked on every save. Another service starts in the background.

Sometimes the feature itself is cheap, but the interface around it becomes heavier. There are more controls to render, more state to restore, more menus to search, and more code that has to run before the user can do the thing they opened the application for.

Individually, these costs are easy to dismiss. Together, they decide whether something responds when clicked or needs a spinner to reassure you that it noticed.

I’m unusually sensitive to this. When a tool becomes slow, I first stop using it for small jobs. Then I use it less often in general. Eventually I look for something simpler, even when the replacement can do less.

That is how I ended up writing mdview. I wanted to read a Markdown file beside my terminal. Converting it through Pandoc was too much ceremony for a quick look. Typora takes about 2.5 seconds to open on my machine. Depending on what I’m working on, I might open Markdown files several times a day or only a few times a week, but the delay bothered me every time.

mdview opens a small file in about 267 milliseconds and a large one in about 426 milliseconds.

Writing an application to save roughly two seconds isn’t an efficient use of time. I did it because the delay was changing how I used the tools I had.

Performance work also competes with feature work. A new capability has a clear result and a changelog entry. Gradual improvements to startup time or responsiveness are less visible. There is rarely one release where the application suddenly became slow, so there is rarely one release where somebody feels responsible for fixing it.

The scale icon is right there

Interface complexity is harder for me to describe because I don’t have one perfect before-and-after story. It’s a pattern I notice across many products.

Sometimes I only want to weigh something on the Thermomix TM6. The appliance already has a scale, and the scale icon is visible. But the display is still catching up with a swipe, so I have to wait before I can tap it.

Looking through recipes has the same problem. Swiping feels delayed, screens buffer, and the interface gets in the way of an appliance that’s already physically in front of me.

The MagentaTV stick I used was even slower than the Thermomix. New Reddit turned reading a page into something that feels heavier than the old site it replaced.

The implementation details differ, and I can’t always tell which feature caused which delay. The experience is consistent: the main action is available, but reaching it feels like operating the surrounding product first.

When you build a feature, navigation is easy. You know which menu contains it because you put it there and opened it fifty times while testing. A user sees the interface without that history. Every additional item has to compete for attention with the reason they opened the product.

This is one reason mdview is read-only. It has no editing mode, plugin system, note linking, or browser-like navigation. Those are normal features for Markdown applications, but each would give the program another job. I open it to read one file and close it again, and I want that action to stay obvious.

Scope before anyone asks

I don’t get many external feature requests yet. My projects are small, and most of the features I have rejected were ideas I had myself.

That is probably the easiest time to defend a project’s scope, because nothing has been promised to anyone yet. It’s also easy to think, “I could add this later,” without asking what the project would become if I did.

I use a rough sentence for each project:

mdview is somewhere between less and Electron. Fast enough to feel like a small tool, and capable enough to read Markdown properly.

Stelae uses WordPress where I find it useful, which is editing, but not for serving the public website.

Transfer gets a remote file into Nextcloud without routing it through your laptop or relying on your connection for the whole download.

They help me notice when an idea would give the project another purpose.

mdview has no XWayland fallback. I didn’t use GTK. It doesn’t edit files or host plugins. Any of those decisions could be argued differently, but each would add another compatibility or interaction path.

Stelae isn’t meant for shops, membership sites, native comments, or an application dashboard. WordPress can support all of those, but Stelae would become a different system if it tried to expose them through a static public site.

Transfer’s immediate-download option was close to the boundary. In the end, I accepted it because it improved the action Transfer already exists for. A user who pastes a URL usually wants the file in Nextcloud now, especially when the transfer itself may take less time than waiting for cron.

That decision was still expensive. I decided Transfer should pay for it.

2.6 MB is not the point

None of this means useful software should be tiny. mdview is a 2.6 MB stripped binary, but it normally uses between 34 and 67 MB of memory. It performs real layout, text shaping, syntax highlighting, image loading, and text selection. Removing those things would make it smaller and worse at the job I built it for.

Transfer’s releases before 1.0 included a lot of work users couldn’t see. I simplified the frontend, replaced legacy APIs, cleaned up dependencies, and fixed behavior across Nextcloud versions. That work didn’t create exciting screenshots, but it made the later release possible.

Software can have a large scope and still defend it well. SQLite does a great deal, but its purpose remains clear. The Linux kernel supports an enormous amount of hardware and functionality because that breadth is part of its job. OpenBSD and Lua make very different trade-offs, but both have resisted at least some pressure to become every adjacent product.

Almost every feature is useful to somebody, so asking whether a feature is useful has never helped me decide. I try to ask whether it improves the job the product already performs, or quietly gives the product another job.

The immediate-download checkbox improved Transfer’s existing job, so I added it. Plugins would give mdview another job, so I haven’t added them.

The cost of one more feature isn’t the code that ships it. It’s every year after that, keeping it as reliable as it was the day it was added.

Similar Posts