# What the Change Feed pushes did to the bill: the container dropped, the Function app didn't

My [last caching post](https://blog.koorevaar.com/articles/caching-in-front-of-a-scale-to-zero-container) ended with a promise: once Cosmos DB Change Feed pushes stopped reads from waking the blog's Container App, I would compare the bill before and after. This is that comparison. It's an intermediate result, because the last step went live only six days ago. It also has a result I didn't expect: over the whole month the pushes cost more than they saved, partly because I cached the article but not the comments that load alongside it.

## What was measured

Azure Cost analysis, grouped by resource, daily, September 25 to October 25, the month right after the first post. The baseline from that post was August 25 to September 25: blog-service-api €7.28, and €0.92 for the Function app (the function, its storage account and its Log Analytics workspace).

The month in one line: blog-service-api €6.96, Function app €7.57. Together €14.53, against €8.20 for the baseline month. So over the whole window the pushes made things more expensive, not cheaper. The month hides a step in the middle, which is why the rest of this post is daily.

What changed, in order:

| Date | Step |
|---|---|
| Sep 16 | Article count pushed to KV |
| Sep 26 | Article list push actually working (it had failed on every run before) |
| Oct 16 | Article list served from KV only, plus a 5-minute timer push for scheduled articles and deletes |
| Oct 17 | View counts counted at the edge, flushed to Cosmos by an hourly Function |
| Oct 19, 02:11 UTC | Article pages, `.md` and comments served from KV only |

![Line chart of daily Azure cost from September 25 to October 24: the Container App at about 0.5 to 0.7 euro a day until October 18 and about 1 cent after October 19, with zero from October 1 to 9 because of the free grant; the Function app rising from about 0.1 to about 0.42 euro a day](https://images.koorevaar.com/diagrams/cost-of-change-feed-pushes-before-and-after.svg)

The whole story in one picture, before the numbers behind it.

## The Container App: a step, on the right day

Average daily cost of blog-service-api:

| Period | Per day |
|---|---|
| Sep 25 to 30 | €0.32 |
| Oct 1 to 9 | €0.00 (see below) |
| Oct 10 to 15 | €0.57 |
| Oct 16 to 18 | €0.51 |
| Oct 20 to 24 | €0.01 |

October 19 is the cliff: €0.56 the day before, €0.06 on the day itself (the switch happened at 02:11 UTC), about a cent a day since. On two of the last three days the bill rounds to zero.

Two things in this table need explaining.

**The first nine days cost nothing, and that isn't the caching.** Container Apps has a monthly free grant of vCPU and memory seconds. As far as I can tell it's shared by every Container App in the subscription, and it resets on the first of the month. The blog API simply ate through it by October 10, and billing started from there. So the first nine days hide what the container was doing. The paid days show it: the replica metric says the same thing without any arithmetic. In 6-hour buckets, from September 25 to October 18 the container had at least one replica in every single bucket: 96 of 96. For those weeks, "scale to zero" was an accounting fiction. The container was awake around the clock, woken by crawlers and by my own pages.

**October 16 saved nothing.** That was the day the article list stopped calling origin: €0.54 the day before, €0.56 on the day, and €0.39 the day after. No step, and I had expected one. The reason is the finding I'd least like to repeat. When someone opens an article, the page fetches two things at the same time: the article, and its comments. The article came from the cache, and that cache worked. The comments did not: that call went to the container, uncached, with no timeout and no fallback. So the cached article didn't help at all. The container woke up anyway, because the comments were loaded at the same moment, and the reader waited for the whole cold start before the page was complete. I only noticed on October 18, because the series links between two articles felt slow. All the caching in the first post protected the article and the list. A single uncached call on the side defeated it.

The fixes, in the order they landed. First a 1.5 second timeout, so the page renders with "comments are still loading" instead of waiting (October 19, no cost effect: the request still ran and still woke the container). Then the actual fix, also on October 19: comments are cached now too. A Change Feed trigger pushes the published comments to KV whenever one changes, a 15 minute timer pushes them as a safety net, and the page reads them from KV like it reads the article. That one is the step in the chart.

It reverses a choice from the first post, where I left comments uncached because someone who just posted one expects to see it on reload. The push makes the delay short instead of zero: a new comment shows up once it's published and synced, not the instant it's saved. For a blog with a handful of comments, that's a trade I'll take.

## The Function app: the bill moved here

If the container saved money, the pushing Function should cost something. Here is the same view for the Function app, summing the function, its storage account and the Log Analytics workspace:

| Period | Per day |
|---|---|
| Aug 25 to Sep 25 baseline (month average) | €0.03 |
| Sep 25 to 30 | €0.12 |
| Oct 1 to 9 | €0.19 |
| Oct 10 to 15 | €0.24 |
| Oct 16 to 18 | €0.35 |
| Oct 20 to 24 | €0.42 |

Every step up lines up with a step in the table above: October 16 (the 5-minute timer), October 19 (detail and comments pushes, the comments timer). It's now roughly €13 a month, up from €0.92.

The break-down is the unflattering part. Of the €7.57 over the month:

| Part | Cost | Share |
|---|---|---|
| Log Analytics data ingestion | €5.95 | 79% |
| The function's own execution time | €0.57 | 8% |
| Storage account (list/create container and read operations, the Functions runtime's housekeeping) | €1.04 | 14% |

The storage account is the other part I haven't explained: about half of its €1.04 is list and create container operations, which sounds like the Functions runtime's own housekeeping, and I haven't checked. The code I added costs a few cents a day to run. What costs money is the telemetry around it. I assumed it was log lines, so I queried the workspace's `Usage` table for what was actually ingested, per table:

| Table | Ingested (Sep 25 to Oct 25) | Share |
|---|---|---|
| AppMetrics | 1,717 MB | 72% |
| AppTraces (the log lines) | 306 MB | 13% |
| AppPerformanceCounters | 300 MB | 13% |
| AppRequests, AppExceptions, AppDependencies | 54 MB | 2% |

So I was wrong about the cause. The log lines are 13%, and one row per invocation and per dependency call (requests and dependencies) is next to nothing. Nearly three quarters is metrics, and another 13% is performance counters, both of which the Application Insights integration sends on its own, whether or not my code logs anything. The daily volume follows the changes: about 35 MB a day in the last days of September, 57 in early October, 104 after October 16 and 129 from October 20 to 24. More functions in the app means more metric series, and every function I added to push to KV made that grow. I haven't proven that mechanism, it just fits the days. Some of the October 23 to 25 volume could also be my own testing.

At about €2.50 per GB that is where the €5.95 comes from, and it means the fix is a setting, not a rewrite.

## Net

| | Container App | Function app | Together |
|---|---|---|---|
| Oct 10 to 18, per day | €0.55 | €0.28 | €0.83 |
| Oct 20 to 24, per day | €0.01 | €0.42 | €0.43 |

About €0.40 a day saved, or roughly €12 a month at today's rates, if the container had kept costing €0.55 a day. Over the actual month it didn't work out that way: the pushes were live for weeks while the container still ran, so the month closed at €14.53 against €8.20. That is the honest monthly number, and the daily rates are what I expect the next month to look like. So yes, at today's rates the pushes are cheaper than the container time they replaced. But the Function side is now larger than the container side ever will be, and with the telemetry switched off it should cost a fraction of what it costs today. The saving isn't the headline. The ratio is.

## What it bought besides money

Article pages, `.md` and comments now come from KV in 0.2 to 0.6 seconds, with no cold start, including the comments call that used to wake the container on every view. And the view-count undercount from the first post is gone: views are counted from a page beacon and flushed to Cosmos every hour, so the number no longer depends on how often the cache refreshed.

## What I'm not claiming

- **Six days of "after".** Each day is close to the others, but a quiet week for crawlers would flatter the result. The real answer is a full month after October 19, so around November 19.
- **The free grant makes the baseline soft.** The container's first nine days of October aren't visible, and its true cost under the old design may be higher or lower than the paid days suggest. September's €7.28 sits inside the same grant logic.
- **Cloudflare KV is a cost I only half measured.** The articles namespace shows about 31,800 reads, 1,100 writes and 40 lists over the last 30 days. Its busiest day had about 2,300 reads (October 19, the day of the backfill and my own checks) and about 400 writes (September 26). The free plan allows 100,000 reads and 1,000 writes a day, so the pushes use a few percent of the read limit and at most around 40% of the write limit on one bulk day. That's the one namespace; the others (counters and so on) aren't in that number.
- **Cosmos stays the same.** The Change Feed pushes read from and write to the same shared 1,000 RU/s pool as before, so there's no Cosmos line in this comparison. The sync functions' lease containers fit in the same pool.
- **The container is not at zero all the time.** Since the switch, 7 of the 20 six-hour buckets from October 20 to 24 still show a replica (October 23 shows none). The cost, about a cent a day, says those wake-ups are short. I haven't looked at what caused them: a first-ever visit to a new article, the `.md` rate-limit fix, or something I haven't switched yet. The metric grain is 6 hours, so it can't say how long each one lasted.

## What's next

1. Stop the Function app exporting metrics and performance counters to Application Insights. That's the largest avoidable line by far. The change is in a pull request and not deployed yet, so I can't say what it saves. If it works, it gets its own short post with the before and after from the same Usage query.
2. Re-measure after a full month, and put the real final numbers at the bottom of this post.
3. Decide whether the blog API is now cheap enough to leave alone. At a cent a day it is, and `min-replicas 1` (about €7 a month) would have cost more than the whole optimisation saves. The €7 comes from the first post, where I priced the pinned replica using the hangman game. That game cost only €0.05 in this window, so I'm not re-measuring it here.

The lesson I'd keep: measure at the call level, not the layer level. I added caching for the list and for article pages, and the bill didn't move until I found the one request nobody had cached. And a cost moved rather than disappeared: it left the container and entered a log workspace, where I wasn't looking.

---

*Co-authored with Claude.*

If this was useful, [you can buy me a coffee](https://ko-fi.com/p47k0).

New articles: [follow via RSS](https://blog.koorevaar.com/feed.xml).
