← Back to the Build Log Products

How serverless almost killed my app

D
Daniel · Apr 13, 2024 · 4 min read
A frozen, frost-covered cloud function wired to a desktop app window stuck on a loading spinner, a clock ticking nearby, and a steady always-on server glowing warmly to the side

A couple of years ago I finally sat down to put a payment model on CommandGit, the Windows tool that later became TerminalNexus. The app itself was done. What it needed was a way to take money and check licenses, and that is where I made a decision I had to walk back.

The hard part of charging for a desktop app is that there is no login wall doing the work for you. A normal SaaS controls access at the front door: you sign in, the server knows who you are, done. A desktop app installs on someone's machine and has to phone home to a backend to ask whether this copy is paid for and whether the trial has run out. I wanted that backend cheap to sit idle, and able to handle a crowd if one ever showed up.

So I reached for Azure Functions. Serverless looked perfect on paper. A free tier, pay only for what you use, and it scales itself if traffic spikes. I already knew .NET, so writing the functions felt like home. I wired up PayPal for the actual payments, put NGINX in front for throttling so nobody could hammer a paid endpoint into a billing nightmare, and stored everything in Cosmos DB through its MongoDB API. The whole thing came together and I was pleased with it. Maybe a little too pleased.

The mistake I baked right into startup

Here is the part that would be me, in case you are looking for someone to blame. I put the license and trial check at application startup. Every time CommandGit launched, the first thing it did was call my serverless backend to confirm the user was legit, and only then open the window.

If you know how serverless works, you already see it coming.

Cold starts

Users started telling me the app was slow to open. Not always. Sometimes it popped up fine, and sometimes it just sat there. When I dug into it, the culprit was the thing serverless does on purpose: when a function has not run for a while, the platform shuts it down to save resources. The next call has to spin a fresh instance up before it can do anything. That is a cold start, and on the free tier it could take up to 30 seconds.

Thirty seconds is nothing for a webhook nobody is watching. For a desktop app it is forever. You double-click an icon and you expect a window, not a progress spinner while my function wakes up in a data center somewhere. Scale to zero, which is the whole selling point, also means scale to cold, and I had put that cold start directly in front of the one moment a user is staring at the screen waiting.

Trying to keep it warm

I did not want to rewrite the backend I had just finished, so I went looking for a way to dodge the problem instead of fixing it. The trick people use is to poke the function on a timer so it never goes idle long enough to be shut down. I set up Azure Logic Apps to call my functions periodically and keep them warm.

It helped. It did not solve it. There were still gaps, still launches that hit a cold instance, and "mostly fast" is not good enough for the first thing a paying customer sees. I was spending effort and a bit of money to paper over a problem that came from using the wrong tool, not from using the tool wrong.

Where it landed

In the end I moved the backend off serverless and onto a plain dedicated Linux instance on Azure, with NGINX out front. Cold starts disappeared, because nothing is being torn down between requests. The instance is always there. It costs a predictable amount instead of a tiny-most-months, who-knows-occasionally amount, which for a backend that has to feel instant is a trade I will take every time.

The good surprise was how little code had to change. The Cosmos DB work carried straight over, the functions became normal endpoints, and most of what I had written kept doing its job in the new home.

The lesson stuck with me. Serverless is great when the caller is patient and the work is bursty. It is the wrong place for anything sitting in a path where a human is waiting, like app launch. Cheap and scalable did not matter once it made the product feel broken. The boring always-on box won.

Thanks for reading. If you have shipped a cold start into a spot a user actually feels, I would love to hear how you found it.

Comments