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, which took longer than everything else put together.

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

The visible change is tiny. The implementation isn’t. An immediate transfer keeps a PHP web worker occupied while the file downloads, so it needs to be cancellable, otherwise one abandoned request can sit there forever. And cancellation needs to work when the user clicks a button, closes the dialog, navigates away, or just closes the browser.

What I shipped uses a heartbeat: the browser polls for progress, and the server cancels the transfer if the polling stops. 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 just hidden). I wrote about the implementation in more detail in When the open tab is the contract.

Making the basic path work was only the start. Before the 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 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. And one Nextcloud 34 change caused successful activity entries to be displayed as malformed.

None of that was visible when I first pictured the feature. I saw the checkbox, the new code path and the happy case, not the existing assumptions the new path has to fit into (there are always more of those than I expect).

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 grows at a manageable rate. Keeping all the surrounding behavior consistent is what gets harder.

A new option might need to respect an existing permission check, a new background task might need to show up in the activity log, a new button needs disabled, loading, success, cancellation, retry and error states. A new way to save something has to update every place that assumed there was only one way to save.

Not every feature is like that, some stay almost completely isolated. But the ones that touch a shared path immediately become part of a much larger system.

I think that’s one reason mature software gets harder to change even when the code isn’t bad. More things have become true over time, and every change has to avoid making any of them false.

My own experience here is limited, I maintain small projects, not Firefox or Kubernetes. Most of what I know about software outgrowing its original shape is second-hand, from bug reports, postmortems, maintainer discussions, and years of watching products change.

It still affects how I work. I’d rather be cautious too early than discover the same problems after people depend on every accidental behavior I shipped.

Features also cost speed

Sometimes the cost is direct: a new query when the application opens, another condition checked on every save, another service in the background.

Sometimes the feature itself is cheap but the interface around it gets heavier. More controls to render, more state to restore, more menus to search through, more code that runs before you can do the thing you opened the application for.

Each of these is easy to dismiss on its own. Together they decide whether something responds when clicked or needs a spinner to reassure you that it noticed.

I’m probably unusually sensitive to this. When a tool becomes slow, I first stop using it for small jobs, then I use it less in general, and eventually I look for something simpler, even if the replacement can do less.

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

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

Writing an application to save two seconds is obviously not an efficient use of my time (I know). I did it because the delay was changing how I used the tools I had.

Performance work also competes with feature work, and it usually loses. A new feature has a visible result and a changelog entry, a slightly faster startup doesn’t. There’s rarely one release where the application suddenly became slow, so there’s rarely one where somebody feels responsible for fixing it.

The scale icon is right there

Interface complexity is harder to describe because I don’t have one clean before-and-after story, just a pattern I keep noticing across products.

Sometimes I only want to weigh something on the Thermomix TM6. It has a scale built in and the scale icon is right there on the display. 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 had was even worse. New Reddit turned reading a page into something that feels heavier than the old site it replaced.

I can’t tell which feature caused which delay in any of these, but the experience is the same: the thing I want is there, but getting to it feels like operating the surrounding product first.

When you build a feature, navigating to it is easy. You know which menu it’s in because you put it there and opened it fifty times while testing. A user sees the interface without that history, and every additional item competes for attention with the reason they opened the product.

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

Scope before anyone asks

I don’t get many feature requests yet (my projects are small), most of the features I’ve rejected were my own ideas.

That’s probably the easiest time to defend a project’s scope, 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 a second purpose.

mdview has no XWayland fallback, doesn’t use GTK, doesn’t edit files and doesn’t host plugins. Each of those 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. I added it in the end because it improves the one action Transfer exists for: someone who pastes a URL usually wants the file now, especially when the transfer itself takes less time than waiting for cron would. It still cost a lot more than I expected, but I think it was worth it.

2.6 MB is not the point

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

Transfer’s releases before 1.0 were mostly work users couldn’t see: simplifying the frontend, replacing legacy APIs, cleaning up dependencies, fixing behavior across Nextcloud versions. Not exciting, but it made 1.0 possible.

Software can also have a large scope and still defend it well. SQLite does a lot, but its purpose stays clear. The Linux kernel supports an enormous amount of hardware because that breadth is its job. OpenBSD and Lua make very different trade-offs, but both have resisted at least some of the 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 anything. What helps is asking whether it improves the job the product already has, or gives it another one.

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. Shipping the checkbox was the cheap part, I’ll be paying for it in every release after this one.

Similar Posts