Engineering
Generous storage that still makes money: a hard cap, real-byte metering, and pack overage
A photographer's plan now includes up to a terabyte of gallery storage. Here is how we made the included space generous without turning it into a loss: enforce the cap when photos go in, trust the size the store reports rather than the one the browser claims, and sell the overflow as simple fixed packs.
The short version
- Photographers told us five gigabytes was nothing. One wedding can be 10 to 40 GB of files.
- So we raised the included gallery storage to 5, 50, 250 and 1000 GB across the four plans.
- It stays profitable because the cap is enforced when a photo is uploaded, not after a bill goes out.
- We meter the real size the store actually holds, measured server side, so a wrong or missing number from the browser cannot slip a file past the cap.
- Anything beyond the included space is bought as fixed 25 GB packs, so the generosity always has a floor under it.
When we opened the photographer vertical, the first piece of feedback was blunt: five gigabytes is not enough for one job. That was fair. A single wedding, shot in RAW and delivered as full-resolution JPEGs, runs to tens of gigabytes before you count the watermarked previews we generate for the proofing gallery. A storage number that looks fine for a salon's before-and-after shots looks insulting to a wedding photographer. So we raised it. The interesting part was doing that without handing out free disk that quietly eats the subscription.
Generous, then bounded
The new included storage is 5 GB on Solo, 50 GB on Studio, 250 GB on Pro and a full terabyte on Scale. Those numbers sound expensive until you price the bytes. The galleries sit on object storage with no egress fee, which is what lets us promise clients unlimited downloads, and stored bytes are cheap enough that even a completely full allowance costs a small slice of what the plan brings in. The exact economics are ours to worry about; the shape of them is what makes the promise possible.
Most customers will not sit at the cap, so the real average is lower again. The headline number can be generous because the thing behind it is cheap. The risk was never the included storage. The risk was having no floor under it.
Enforce on the way in, not on the way out
A generous allowance with no enforcement is just an unbounded cost with extra steps. So the cap is checked at upload. Before a batch of photos is written, we work out how much room is left, then refuse any file that would push the studio past its plan plus whatever packs it has bought. Nothing is deleted, nothing is billed by surprise. The upload just stops at the line and tells the photographer to clear space or add a pack.
The part that matters is how we count. The browser sends a size for each file, and you must not believe it. A size of zero, or a number someone edited in the request, would walk straight past a naive check. So for every file we ask the store itself how many bytes it is holding, in parallel, and use that. The client number is only a fallback for the case where the store cannot be measured at all, such as a local dev box with no bucket, and even then we never read a missing size as free.
// Refuse any photo that would push the studio past plan + bought packs.
const status = await galleryStorageStatus(org); // cap = tier inclusion + packs
let remaining = status.remainingBytes;
// Ask the STORE how big each file really is, in parallel. A forged or missing
// client-supplied size must never be trusted this close to a hard cap.
const measured = await Promise.all(
items.map(async (it) => {
const real = (await blobSize(it.imageUrl)) + (await blobSize(it.previewUrl));
// Fall back to the client number only when the store can't be measured
// (e.g. local dev), and never treat a missing size as zero/free.
return real > 0 ? real : Math.max(0, Math.round(it.bytes ?? 0));
}),
);
for (let i = 0; i < items.length; i++) {
const bytes = measured[i];
if (bytes > remaining) { blocked++; continue; } // over the cap: refuse this one
await addCustomerPhoto(org.id, customerId, items[i], { bytes });
remaining -= bytes;
}Overflow is a product, not a punishment
When a photographer does fill the allowance, the answer is not a scary overage meter ticking up per gigabyte. It is a fixed pack: 25 GB more for a few dollars a month, stackable. The overflow is priced so that the generous included space stays sustainable, and a pack is easy to reason about on a bill, which matters more than a clever per-byte formula nobody can predict.
Because money and the in-app limit must agree, Stripe is the source of truth and the cap follows it. The number of packs is a quantity on the studio's subscription, and the storage limit the upload check reads is derived from that quantity. If the two ever drift, the billed quantity wins and the limit is corrected, never the other way around. We would rather a customer briefly have slightly less room than briefly pay for room they cannot use.
One sharp edge: the size column
There is a small trap worth naming. The per-photo size is stored in a 32-bit integer column, which tops out near 2.1 GB. That is fine for a real photo, since no single image is anywhere near two gigabytes, and the running total is summed as a 64-bit value so an org can hold far more than that overall. We only tripped over it in a test that tried to fake a five-gigabyte file in one row to push an account over the cap. The database rejected the row outright. The fix in the test was to spread the bytes across several rows, which is also what real life does. It is a good reminder that a column type is a quiet contract, and tests should respect it rather than route around it.
Generous is a pricing decision. Bounded is an engineering one. You need both, or the first one bankrupts you.
The result is a number we are happy to put on the pricing page. A wedding photographer on Studio gets room for real jobs, a busy studio on Pro gets a quarter of a terabyte, and the heaviest accounts can buy more in chunks that pay for themselves. The generosity is real, and so is the floor underneath it.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs