99.5% of Our Transformations Were Uploads: The Nightly Sync That Cost Two Credits a Month
Table of Contents
On weekends, our users mostly go home. Delivery requests to Cloudinary drop by 90%.
Transformations, the line we pay the most for, drop by 5%.
That gap held every week from July to October, and it’s the whole story.
Part 1 ended on a loose thread: storage wasn’t our biggest Cloudinary line, transformations were, 1.35 of the 2.29 credits the US environment used in a month. I’d assumed that was what it looked like, images being resized on the way to browsers. It wasn’t. It was a nightly job re-uploading every employee avatar we have, whether it had changed or not, and Cloudinary counting each one as a transformation because that’s what the pricing page says it does.
The fix is one boolean. Finding out which boolean took a support ticket.
A Spring Boot monolith with user files on Cloudinary, two product environments (US and EU), plan metered in credits where one credit is a million transformations. Client details are anonymised; the counts come from the usage API, the console and Cloudinary support, 10 to 23 October 2026.
1. The line that didn’t move with the users
The usage API gives a per-day breakdown, so the first thing I did was pull twelve weeks of it, 20 July to 11 October, and put requests and transformations side by side.
US environment, 20 Jul to 11 Oct, weekend against weekday
delivery requests -90%
transformations -5%
Saturday 10 October: 42,156 transformations, 1,436 requests, 0.18 GB of bandwidth. Nobody was looking at anything and the meter ran at full speed.
Two more numbers said the same thing from a different angle. derived_resources, the count of transformed variants Cloudinary keeps, grew from 409,241 to 415,321 between 30 September and 13 October. Six thousand new variants, against 557,000 transformations counted over the same days. If those were resizes, the variants would be piling up. They weren’t.
And on the EU environment, transformations jumped from 19,859 on Thursday 1 October to 32,555 on Friday 2 October, and stayed there, while requests were flat at about 21,000 a day.
Whatever this was, it wasn’t traffic.
2. What it wasn’t
Cloudinary can count a transformation without a browser asking for one in a handful of ways: a rule on an upload preset that resizes at upload time, an explicit call from the backend asking for a variant, an AI add-on tagging images, a workflow (MediaFlows). Every one of them read zero on our account, and our upload calls pass no transformation options at all.
A colleague pointed at the delivery URLs, and it was a fair guess: f_auto on an image URL produces one variant per format the CDN decides to serve, each counted once, and we don’t have strict transformations enabled. True, and worth its own clean-up. But delivery was the line that lost 90% on weekends, and the count didn’t move. Views were a rounding error on this line.
So the usual suspects were clean, and the numbers were still 40,000 a day. At that point the only party who could see what the 40,000 were was the vendor.
3. Ask the vendor for the breakdown
The support ticket laid out the numbers above and asked one question: for 10 October on the US environment, what generated the transformations, by category? The answer came back the same day.
| 10 Oct, US environment | Units | Share |
|---|---|---|
| Uploads | 41,930 | 99.5% |
| On-the-fly derived (delivery) | 226 | 0.5% |
| Eager / explicit | 0 | |
| Analysis | 0 | |
| MediaFlows | 0 |
Uploads. Not resizes, not formats, not analysis. The EU step on 2 October was the same thing: upload units went from 18,075 to 29,797 in a day, on-the-fly barely moved.
The second question narrowed it to a time and a place. Split the uploads by hour and by top-level folder:
US, 10 Oct, uploads under users/
01:00 UTC 41,915
EU, 2 Oct, uploads under users/
01:00 UTC 27,666 (plus 384 later that morning)
Forty-two thousand uploads, one folder, one hour. At 01:00 UTC, every night, something was uploading every user avatar we have.
4. The job, and the sentence in the pricing docs
We know what runs at 01:00 UTC. The HRIS sync does, HRIS being whichever HR system a tenant plugs in: for every tenant, it pulls the employee list from the HR system, upserts the users, and for every employee with a photo, uploads the photo to Cloudinary under users/ with overwrite=true. Same public id every night, same bytes almost every night.
01:00 UTC, every night, per environment
HRIS ──> for each employee ──> has a photo? ──> upload(users/<id>, overwrite=true)
|
same bytes as yesterday,
for most of them
The sentence that turns that into a bill is in Cloudinary’s transformation counting basics:
If you re-upload (overwrite) an asset with the same public ID in the future, that upload is again counted as a transformation.
An overwrite is a transformation, identical bytes or not. Support confirmed it in so many words when I asked.
The counts then line up without any estimation. A query on the production database for users whose avatar lives under users/, plus the same query on the playground database, which shares the US Cloudinary environment with production (the Part 1 problem again, from the other side):
| US | Users with an HRIS avatar |
|---|---|
| production | 23,076 |
| playground | 17,750 |
| total | 40,826 |
Against 41,930 uploads counted by Cloudinary on 10 October. The difference is a day’s worth of new photos and the preprod environment, which also writes to the same account. The sync itself touches about 200,000 employees a night across production and playground; the ones with a photo are the uploads.
So the 1.35 credits weren’t a delivery cost. They were 1.26 million re-uploads a month of pictures nobody had changed, most of them counted twice, because playground was running the same sync into production’s account.
5. The fix is which boolean you send
Cloudinary’s upload call has the answer built in. With overwrite=false, an upload whose public id already exists isn’t processed; the response comes back with existing: true and the asset is left alone. The question was whether that request is still charged, and the pricing page doesn’t say. Support did, in writing:
existing: true asset already exists, not replaced -> no charge
existing: false asset is new, uploaded -> 1 transformation unit
overwritten: true existing asset processed as overwrite -> 1 unit, even if the bytes are identical
So the sync keeps overwrite=true only for employees the HR system marks as changed in the last 72 hours, or with no modification date at all. Everyone else goes with overwrite=false, Cloudinary answers existing, and nothing is processed or billed. A new tenant, or an avatar that failed to upload earlier, still gets uploaded, because the asset doesn’t exist and existing: false does the right thing.
// simplified
boolean changedRecently = employee.modifiedAt() == null
|| employee.modifiedAt().isAfter(now.minus(72, ChronoUnit.HOURS));
var options = ObjectUtils.asMap(
"public_id", "users/" + employee.id(),
"overwrite", changedRecently); // false: existing photo stays, request not billed
var response = cloudinary.uploader().upload(photo, options);
Two smaller changes went in with it. Employees the sync was going to skip anyway (no email, or an internal address) no longer trigger an upload at all. An upload failure is logged once, with the tenant and the public id, instead of being swallowed. Neither moves the bill; both were in the way while reading the code.
Nothing about the avatars themselves changes. The same photo is served from the same public id. The only difference is that Cloudinary is no longer asked to replace it with itself every night.
6. Measured, the day after
The fix merged on 20 October; 22 October was the first full day with it on the usage API.
| Transformations per day | before | after | |
|---|---|---|---|
| US environment | 41,900 | 8,800 | -79% |
| EU environment | 31,900 | 12,200 | -62% |

What’s left is the sync doing its job on employees who did change, plus whatever the other environments still send; I haven’t split the remainder, so I won’t guess at it here.
Credits are metered over a rolling 30 days, so the total catches up slowly: 3.31 across both environments on 13 October, 2.71 on 23 October. The job itself was about 2.1 credits a month, 70,000 uploads a night times 30. The two drops above take about 1.6 of those back once the window has rolled over; the remainder is the unsplit part above.
Two credits a month on one boolean, most of it already back.
7. What generalises
A metric that doesn’t follow your traffic isn’t about your traffic. The weekend ratio was the whole diagnosis, and it was sitting in the usage API from July. Requests fell 90%, transformations 5%; nothing a user does could produce that shape. Before reading code, put the suspicious line next to one you understand and look at the weekends.
“Transformation” means what the vendor’s counter says it means. I read the word and thought resize. The counting page says an upload is one, an overwrite is one, and an eager variant is one each. Our 40,000 a day were none of the things the word suggests. A day of checking presets, for one sentence sitting under the word.
Ask the vendor for the breakdown before you’re sure. Everything on our side was clean after a day of checking, and it would have stayed clean, because the cause wasn’t a setting, it was a correct call made 70,000 times too often. Support had the per-category split in a day and the per-hour, per-folder split in another.
The same account under three environments counts everything up to three times. Part 1 found it with a clean-up job that deleted what the others uploaded. This is the same wiring from the other side: playground’s nightly sync was writing into production’s quota. Any job that talks to a shared external account has to know which environment it’s running in, and most don’t.
If a vendor charges per operation, the cheapest operation is the one you don’t send.
This closes the series for now. Part 1 is the delete that freed nothing, Part 2 is the clean-up job and the Hibernate hook underneath it. For the other time a bill turned out to be a cache key rather than a usage pattern, there’s the feature flag quota piece.



