| title | Cost model | ||||
|---|---|---|---|---|---|
| audience | product | ||||
| summary | Per-line-item rates, write-amp meter, compression posture. | ||||
| last-reviewed | 2026-07-01 | ||||
| tags |
|
||||
| related |
|
The cost meter to watch is billable Class A object-storage
operations: PUTs and LISTs. Class B GETs are much cheaper. Unindexed
reads mostly stay on the Class B meter; indexed .where() reads can
issue Class A LISTs.
The smallest successful write is an unindexed insert on the first try:
one PUT for the content body and one create-if-absent PUT for
log/<seq>. That two-op commit floor matters because the log create
is the commit. There is no current.json write on the commit path.
The billable steady-state number is higher than the floor because successful writes may also run bounded maintenance. Measured effective Class A write-amplification is ~3× on Cloudflare and ~4× on serverful Node. Deletes are cheaper; indexes, retries, and first-collection provisioning add bounded ops. The unindexed read tail forward-probe is Class B GET work, so it does not touch the Class A meter.
All prices below were re-checked 2026-06-22 against the official Cloudflare R2 pricing, Workers pricing, and Workers limits pages. Re-check before quoting any figure externally; price and cap changes land in pricing-log.md, the one-line-per-change history, on the day they ship.
Portfolio number: at N≈30 mostly-idle apps, baerly-storage costs ~$5/mo on Cloudflare (one Workers Paid floor, amortized — not ×30) or $0/mo on self-hosted Node. See the idle × N portfolio comparison further down.
R2 storage and ops dominate at M-size. Worker CPU and request counts are secondary.
| Line item | Rate | Free tier |
|---|---|---|
| R2 storage | $0.015 / GB-mo | 10 GB-mo |
R2 Class A ops (PutObject, CopyObject, ListObjects, multipart) |
$4.50 / 1M | 1M / mo |
R2 Class B ops (GetObject, HeadObject) |
$0.36 / 1M | 10M / mo |
R2 DeleteObject |
$0 | unlimited — delete cleanup is not the bill driver |
| R2 egress to internet | $0 | unlimited |
| Worker requests (Workers Paid) | $0.30 / 1M | 10M / mo (paid plan) |
| Worker CPU-ms (Workers Paid) | $0.02 / 1M | 30M / mo (paid plan) |
| Workers Paid plan floor | $5 / mo | — |
Note on AWS S3 free tier: AWS S3's 12-month free tier no longer applies to new accounts (credit-based since 2025). The self-hosted-Node "idle is free on S3" claim holds only for existing or paid accounts, or for workloads inside a bucket you already own on a paid plan.
Class A is the main meter:
- It has the highest unit cost among high-volume items: $4.50 / 1M vs. Class B at $0.36 / 1M and Worker requests at $0.30 / 1M.
- Writes amplify it: the commit floor is 2 Class A ops, and maintenance raises the effective rate to ~3× on Cloudflare / ~4× on Node.
- Compaction and GC use PUT / LIST / conditional-PUT work. Free
DeleteObjectcalls are not the bill driver.
baerly cost --bucket=<bucket-uri> --collection=<collection> reports
the current write trajectory:
- Class A ops/mo;
- free-tier-aware dollars;
- distance to the advisory line (~100 writes/min; ~$54/mo R2 object-storage ops); and
- distance to the 50M Class A/mo hard graduation trigger.
The projection uses measured effective write-amp, not the two-op commit
floor, so it includes write-path maintenance. Write amp belongs to the
host maintenance profile, not the storage provider: the Cloudflare free
profile measures ≈3×, and the Node profile measures ≈4×. Today
baerly cost maps that onto provider defaults (r2 ⇒ ≈3×, aws-s3 /
self-hosted ⇒ ≈4×). That matches Worker+R2 and Node+S3 defaults, but is
only a projection assumption for cross-provider deployments such as Node
against R2.
For longer windows (7-day, 30-day), pipe the canonical log line to
CloudWatch / Workers Analytics / Datadog; see
docs/guide/observability.md.
baerly-storage publishes and tests a ceiling so cost drift is caught. Three bounds matter:
- Small constant storage ops per logical write. The unindexed
first-try insert baseline is PUT content + the committing
log/<seq>create: 2 Class A ops. The create-if-absent is the commit, so there is nocurrent.jsonwrite on the commit path. Deletes can be cheaper; indexes, retries, and first-collection provisioning add bounded mutations. Snapshot writes amortize across many log entries and are issued by the compactor, not the commit step. Thedb.write.class_a_ops_per_logical_writehistogram is emitted on every commit (verified in CI bywriter.test.ts). If you pipe it to a metrics sink, alerting when p99 exceeds ~5 is a recommendation, not a shipped or CI-gated check. - Effective Class A write-amplification is ~3× on Cloudflare and ~4×
on serverful Node. The commit path is still two Class A ops, but
in-band folds and GC run from the write path. They add ~1 Class A
op/write on the cf-free profile and ~2 on Node. Node's
gcInterval=2vs. cf's4doubles the GC LISTs; each GC pass is a handful of LISTs plus per-candidate markGETs and apending.jsonCASPUT, and each fold is 2 PUT. Measured empirically; seedocs/spec/attachments/amortized-write-cost-baseline.json(pnpm bench:amortized-write-cost) and gated bytests/integration/write-amp.test.ts.DeleteObject(the GC sweep) is $0 on R2/S3 and is excluded from this count. < 1 Class A op / writer / hourfor idle readers. For unindexed idle reads, the expected value is exactly zero. They walkcurrent.json, the snapshot, and the live-tail log by deterministic GETs. The tail forward-probe GETs frommax(log_seq_start, tail_hint)(normallytail_hint) to the first 404; those are Class B calls. The read-path exception is an indexed.where(): it issues oneListObjectsClass A call per equality value ($in⇒ N calls) to walk the index prefix and resolve matching_ids. The default fold path (full scan over snapshot + tail) is zero Class A.
The idle-reader bound holds because only writes tick maintenance. A read does zero maintenance work (ADR-002, graduation.md). The cost consequences:
- A bucket with no later writes pays a bounded ≤ ~1× tail replay per
read while folds succeed. At the default
MAINTENANCE_TARGET_RATIO = 1.0, the live tail stays within ~1× the snapshot, so an unindexed reader replays at most about one snapshot's worth of log entries on top of the snapshot. AboveS_max(the snapshot ceilingC/E), the fold defers and the tail grows unbounded. Read cost then climbs with every write since the last fold. That is the graduation cliff, not steady state. - An over-ceiling bucket defers cheaply, not magically. The defer
decision is a zero-storage-op projection over
current.jsonalready in scope, so it avoids a fold attempt. A deferring collection can still do normal writes plus rate-limitedtail_hint/ warning stamps. - Inline-Node fold latency is I/O-dominated, not CPU-dominated.
A fold's wall-clock is roughly
⌈tail / MAX_PARALLEL_LOG_READS⌉storage round-trips. Log-tail GETs are concurrent, capped atMAX_PARALLEL_LOG_READS = 16; the snapshot ceiling bounds CPU/memory, not round-trip count. A future serverful post-response dispatch would move this off the write's critical path. - Node worst case = a fold plus a full GC pass on one write.
Node runs
phasesPerTick: "both", so a single boundary-crossing write can pay both a fold slice and a GC pass. The combined cost is a bounded p99 latency spike that scales with the moderate, latency-budgetedNODE_MAINTENANCE_*caps (fold 200 / marks 200 / sweeps 100). Budget for the combined number, not the fold alone; a future post-response dispatch removes the spike. - Seed-then-idle orphan residual. A bucket bulk-seeded with
admin restoreand then left idle within the 7-day GC grace window can carry a bounded, never-reclaimed orphan pile. Reads are pure, so without later writes nothing ticks andrunGcnever re-runs to sweep marked orphans. This is irreducible under reads-pure, bounded by the import size, and reclaimable on demand through the opt-inrunScheduledMaintenanceSDK. It is a known boundary of the in-band model, not a leak.
The ceiling protects two concrete workloads:
- An app with one daily writer and a handful of pollers should be free on R2 and effectively free on S3. One Class A op per poll breaks that.
- A ~100-MAU helpdesk app should cost single-digit dollars per month. Per-write ops must stay a small constant, not grow with table size, snapshot depth, or history.
The ceiling is a gate, not a target.
tests/integration/maintenance-e2e.test.ts wraps Storage with a
counting proxy and gates on expect(classAOps).toBeLessThan(1) after
1800 polls (one hour at 2 s cadence).
packages/server/src/maintenance.ts carries the CLOUDFLARE_FREE_TIER
bounded-tick arithmetic. Engine defaults are unbounded, so a Node caller
just passes {}. maintenance-budget.test.ts proves each Cloudflare
free-tier compact or GC phase sits under the 50-subrequest cap; the
scheduled handler alternates phases rather than running both in one
free-tier tick.
Per-collection commit scope (see
docs/spec/sync-protocol.md) is what makes
the idle-poll bound tractable: one cheap log series and one compaction
bookmark per collection rather than contention on a global mutex.
Object storage buys low operator burden: no DB process to provision, patch, or page about. A managed relational DB buys a richer query model and a dedicated server, but it brings a per-project floor and an on-call surface. At low write rates baerly-storage is nearly free. At M-size and above, Class A billing compounds with effective write-amp; that bill is the graduation signal.
These are the formulas the baerly cost CLI projection is built on.
W is monthly logical writes (write operations, not documents), and
A is the measured effective Class A write-amplification for the host
maintenance profile. Use A ≈ 3 for the Cloudflare free profile and
A ≈ 4 for the Node profile. All figures use measured effective
write-amplification, not the two-op commit floor.
Cloudflare R2 pricing (default Worker+R2 path uses A ≈ 3):
Class A ops/mo = W × A
R2 object-storage $/mo = max(0, W×A − 1,000,000) × $4.50 / 1,000,000
+ max(0, storedGB − 10) × $0.015
+ Class B reads (typically minor at M-size)
This is the object-storage ops projection baerly cost reports. R2
ops are billed above 1M Class A/mo; storage is billed above 10 GB/mo.
The $5/mo Workers Paid plan is a separate Cloudflare platform
floor, not an R2 charge. It is absent on self-hosted Node,
R2-over-the-S3-API, and the Workers free tier, so baerly cost does not
include it. Add ~$5/mo for the all-in Workers Paid figure. Under 1M
Class A/mo (roughly ≤ 7 writes/min sustained) and under 10 GB, R2
object-storage cost is $0.
AWS S3 pricing (default self-hosted Node+S3 path uses A ≈ 4, no free tier):
Class A ops/mo = W × A
S3 $/mo = W×A × $5.00 / 1,000,000
+ storedGB × $0.023
+ Class B reads (typically minor at M-size)
S3 has no flat floor and no free tier in this model: every write costs
linearly from zero. In the default deployment paths, Node+S3 is roughly
50% costlier than Worker+R2 per write: $20 vs $13.50 per million
logical writes (4 × $5.00/1M vs. 3 × $4.50/1M). The gap comes from both
the higher Node maintenance write-amp and the higher S3 per-op rate. If
you run Node against R2, use R2's rates with Node's A ≈ 4; for a
non-default profile, treat the table as a projection and validate
against db.write.class_a_ops_per_logical_write. The 12-month
new-account free tier was retired in 2025; these figures apply to paid
accounts.
Representative write rates and their projected monthly costs.
Figures are object-storage ops only (storage and Class B reads are
minor until L-size read fan-out and are excluded from these rows), and
exclude the $5/mo Workers Paid platform floor — add it for the all-in
cost on Cloudflare Workers Paid. These figures are what baerly cost
projects. Storage: assume ~100 MB for S-size, scaling proportionally.
| Writes/min (sustained, account-wide) | Class A/mo (Worker+R2, A≈3) | R2 $/mo (object-storage ops) | Class A/mo (Node+S3, A≈4) | S3 $/mo (object-storage ops) | Notes |
|---|---|---|---|---|---|
| 1 | 130k | $0 | 173k | ~$0.86 | Inside R2 free tier (1M/mo) |
| 10 | 1.3M | ~$1 | 1.7M | ~$9 | R2: small Class A overage (+ $5 Workers Paid floor) |
| 30 (M-size) | 3.9M | ~$13 | 5.2M | ~$26 | ~$18/mo all-in on R2 incl. floor — see M-size breakdown below |
| 100 | 13.0M | ~$54 | 17.3M | ~$86 | Advisory crossing: baerly cost prints eyes-open advisory |
| 390 | 50.5M | ~$223 | 67.4M | ~$337 | ≈ 50M Class A/mo R2 graduation trigger |
| 1000 | 129.6M | ~$579 | 172.8M | ~$864 | Well past graduation |
The 390 writes/min row is the 50M Class A/mo graduation trigger at the
default Worker+R2 write amp (A≈3): R2 costs ~$223/mo and the
Node+S3 default path costs ~$337/mo in object-storage ops. The
Node-profile path reaches the same 50M op envelope at ~290 writes/min
(A≈4).
The M-size operating point is a sustained ~30 writes/min. In this
cost-curve table that is an account-wide aggregate rate because
Class A is billed per account, not per collection. It numerically
coincides with, but is different from, the per-collection CAS-contention
ceiling M_SIZE_WRITES_PER_MIN_PER_COLLECTION (the CLI grading
constant). Full arithmetic:
Writes/mo = 30 writes/min × 60 min/hr × 24 hr/day × 30 days = 1,296,000
Worker+R2 default (A≈3):
Class A/mo = 1,296,000 × 3 = 3,888,000
Free tier: 1,000,000 Class A/mo (included)
Overage: 2,888,000 Class A ops
Object-storage ops: 2,888,000 / 1,000,000 × $4.50 = ~$13/mo
+ Workers Paid platform floor: $5.00 (only on CF Workers Paid)
All-in (object-storage ops + floor): ~$18/mo
Node+S3 default (A≈4):
Class A/mo = 1,296,000 × 4 = 5,184,000
Object-storage ops: 5,184,000 / 1,000,000 × $5.00 = ~$26/mo
(no platform floor — serverful Node / S3)
baerly cost surfaces the object-storage ops figure (~$13/mo on R2
here) as projectedUsdPerMonth in the inspect footer. It uses the
provider rates and default effective write-amp constants from
packages/cli/src/cost/provider.ts, and deliberately excludes the $5
Workers Paid platform floor. Add ~$5/mo for the all-in Workers Paid
cost. For cross-provider deployments, use the host profile's measured
write amp when doing manual projections.
$18/mo on R2 buys low operator burden and bytes-in-your-bucket. The number is here for comparison, not as an anchor against any one alternative's price.
Decision: when HTTP-client wire compression ships, default to
compression: false.
Why. The dominant deploy shape is a Cloudflare Worker talking to R2 in the same data center. Worker CPU-ms is metered on Workers Paid, and the intra-DC R2 link has zero egress cost. Gzip spends CPU to save zero billable bytes. The same applies when self-hosted Node and the bucket sit in the same network, such as hosted Minio or on-prem Ceph.
When to flip it on. The trade-off inverts for
BYO-Node-to-remote-bucket: a Node process outside the bucket's network,
where every read or write crosses a paid egress link. Compression then
shrinks billable bytes at the cost of local CPU, which is cheap on a
long-running Node process compared to a per-request Worker isolate. Set
compression: true for that shape once the option exists.
Default. false, with a single-line client-config override once the
option ships.
This decision is logged at pricing-log.md
when it ships in @gusto/baerly-storage/client.
baerly-storage wins on idle × portfolio cost and loses on per-write unit cost past the graduation cliff. Both are intentional.
For the audience-in-practice, the per-app floor is the cost line, not the per-write rate. Costs at N=30 mostly-idle apps:
Every cell uses the same N=30 basis. Non-zero usage-based or per-project floors are shown as per-app basis × 30 estimates and marked est.. Per-project floors of $0 stay $0 at any N. Re-check provider floors before quoting totals externally.
| Service | Cost at N=30 idle apps | Notes |
|---|---|---|
| baerly-storage (Cloudflare) | ~$5/mo | One Workers Paid floor amortized across all N apps (paid once, not ×30). Class A/B ops effectively zero at idle (< 1 op/writer/hour, CI-gated). |
| baerly-storage (self-hosted Node) | $0/mo (your hardware) | No platform floor; idle storage-op cost is $0 against an S3-API bucket. See the AWS free-tier caveat above. |
| Cloudflare D1 | ~$5/mo | Same single Workers Paid floor amortized across all N apps; ties baerly-storage here, but only if all N apps are Workers-native. wrangler d1 export gives a SQL dump, but leaving is a dump-and-reload migration, not a zero-cooperation exit. |
| Supabase Free | $0 | Two free projects per org; not a fleet posture for N=30. |
| Supabase Pro | ≈ $25 org + 29 × |
Pro bills per organization, not per project: one ~$25/mo org fee (includes ~$10 compute credit, ≈ one always-on project), and each additional project adds its own compute from ~$10/mo. A 30-project fleet is the org fee plus 29 more compute instances, not 30 × $25. Compute tier and usage on top vary. |
| Neon Launch | ≈ $5/app × 30 ≈ ~$150/mo (est.) | Usage-based with no monthly minimum and scale-to-zero, but each intermittently-awake app still meters CU-hours; ~$5/app is a typical small-app monthly figure, so a 30-app fleet lands near ~$150/mo. Varies with how often each app wakes. |
| Firebase Spark | $0 while inside no-cost Firestore quotas | Official quota is 1 GiB stored, 20k writes/day, 50k reads/day, 20k deletes/day, per project — a 30-app fleet can stay $0 only while every app stays inside quota. |
A team with 30 internal tools pays one platform floor, or zero on self-hosted Node. For this workload class, the alternative is often not another database; it is the experiment staying in a Google Sheet.
Past the workload ceiling, per-write economics flip. D1 wins per-write where it is available; managed Postgres wins above L.
M-size, in this comparison, means:
- ~100 MAU;
- 10 000 docs;
- ~24 000 writes/day (~50/min over an 8-hour workday);
- ~480 000 reads/day; and
- 100 MB stored.
This is a bursty audience profile, not the same lens as the sustained
~30 writes/min cost-vs-scale table. The profile
has 720 000 writes/mo (~2.16M R2 Class A), below the sustained curve's
1.296M writes/mo (~3.89M Class A). Both are above the 1M Class A free
tier. baerly-storage's modelled cost here is ~$19 all-in, dominated by
the $5 Workers Paid floor plus R2 Class A/B ops; baerly cost reports
only the object-storage-ops portion.
| Service | Plan | $/mo | Notes |
|---|---|---|---|
| baerly-storage (this design) | Workers Paid | ~$19 | R2 Class A/B dominate. |
| Cloudflare D1 | Workers Paid | ~$5 | M is way under D1's 25B reads / 50M writes free tier; just the plan floor. SQL trade-off is on you. |
| Supabase Free | Free | $0 | Fits storage, but the free plan is not a production fleet posture. |
| Supabase Pro | $25 base | ~$25 | Always-on Postgres + Auth + Storage. Roughly parity with baerly-storage + opt-in realtime. |
| Neon Launch | usage-based | ~$15 typical intermittent small app | Scale-to-zero helps for bursty traffic; CU-hours add up if continuous. |
| Firebase Blaze | PAYG | ~$5 (approx.) | 14.4M reads × $0.03/100k ≈ $4.30 + 720k writes × $0.09/100k ≈ $0.65 ≈ ~$5. Roughly baerly-storage ÷ 4 — cheaper per-op at M. Rates: Firestore Standard, us-central1; re-check before quoting. |
| Firebase Spark | Free | $0 if under 50k reads/day; M's 480k/day blows the no-cost read quota. |
Read this as positioning, not a provider quote:
- XS / S: baerly-storage is cheaper than always-on managed DBs, especially across a portfolio; see the idle × portfolio table. The differentiators are schemaless docs, multi-instance causal consistency, bytes-in-your-bucket, and price.
- M: baerly-storage (
$19) is$5 vs. ~$19). That is expected: M is past the design center.4× more expensive than D1 ($5) where D1 is available. D1 is Workers-runtime-only;wrangler d1 exportgives a SQL dump, but leaving is dump-and-reload rather than a bucket-native data layer or live CDC handoff. Off Workers, managed Postgres at $25+/mo behind a vendor catalog is the relevant comparison. If Cloudflare lock-in and SQL are acceptable, switch to D1; that move is the success path, not churn. Firebase Blaze also undercuts baerly-storage on raw per-op at M ( - L: baerly-storage's R2 Class B alone (~$1 500) costs more than a Postgres Pro plan. Read-heavy traffic on a per-doc fan-out protocol is disproportionately expensive compared with a B-tree lookup.
- Portability / switching cost: baerly-storage keeps this advantage
across workload sizes. AWS S3 and Cloudflare R2 are the
production-supported stores; MinIO is the local conformance target;
other S3-compatible endpoints require
baerly doctor --bucketplus owner validation (see storage-compatibility.md and ADR-002). Azure Blob is not an S3 dialect, and GCS's S3-interop endpoint exposes conditional writes as read-only, so both need dedicated adapters that do not exist yet. D1, Supabase, Neon, and Firebase are proprietary runtimes; choosing one is a switching-cost decision.
The axes are per-write price, idle × portfolio cost, and portability /
switching cost. baerly-storage loses per-write price at M-size and
above; baerly export --target=postgres --collection=<name> is the
per-collection graduation path.
- Advisory: sustained ~100 writes/min account-wide. This is
provider-agnostic:
13M Class A/mo on R2 ($54/mo object-storage ops) and17.3M on S3 ($86/mo). It is an eyes-open signal, not a hard stop;baerly costprints an advisory note at this crossing. - Hard cost line: R2 Class A ops > 50M/month, sustained over 7 days (account/bucket-wide; ~$220/mo object-storage ops on R2). At measured effective write-amp, that is ≈ 390 writes/min on R2 (~3×). The same op envelope on serverful Node is ≈ 290 writes/min (~4×), but S3's linear pricing makes the Node line a dollar budget rather than a free-tier-derived op count. Both correct the previous ≈580 figure, which assumed the 2-op commit floor.
- Stored data: a graduation cost signal at the ~10 GB R2 free-tier line, not a hard trigger. The tooling does not enforce a storage hard stop.
- Retired:
effective write-amp > 6. It was calibrated against the old assumed 2-op floor. Effective write-amp is now measured at ~3× / ~4× and stress-measured to peak at ~4× under pathological churn (docs/spec/attachments/amortized-write-cost-stress-baseline.json,pnpm bench:write-amp-stress). The route past ~4× is a CAS-retry storm, governed by the per-collection throughput ceiling. Maintenance falling behind is signalled bydb.compaction.deferred_totaland the deferconsole.warn(see graduation.md).
These sit alongside the other graduation signals in
graduation.md: ~30 logical writes/min/collection
(throughput estimate), ~10 GB/tenant (R2 storage cost signal, not a hard
stop), and ~100 collections/tenant (soft fan-out guideline). Today
baerly cost percentOfGraduation tracks only the Class A trigger.
One more graduation cliff lives on the storage side, not the dollar
side. Under single-write commit, writers racing the same collection all
try to create the next log/<seq> key, so concurrent PUTs concentrate
on one object-store prefix. S3-class stores cap sustained mutating
throughput at roughly 3,500 PUT/s per prefix; a collection near that
line is hitting a per-prefix ceiling, not a pricing limit. This is
inherent to a single linearized per-collection log, the same property
that gives per-collection ordering. It sits well past the published
~30-writes/min/collection envelope. Spreading load across more
collections is the lever.