RSS Import — Compute Cost Baseline
Run date: 2026-04-22 Pricing basis: Cloudflare Workers Paid plan (published list prices, April 2026) Subject: 23-episode RSS feed (Hubhopper source), full streaming import through the production pipeline
TL;DR
| Metric | 23-episode import | 500-episode import (projected) |
|---|---|---|
| Wall-clock duration | 50.13 s | ~10.6 min |
| Total Cloudflare compute + ops cost | $0.00014 | $0.003 |
| Per-episode cost | $0.0000061 | ~$0.000006 |
| Audio bytes streamed to R2 | 120,963,057 (~115 MB) | ~2.5 GB |
| Retries / failures | 0 | — |
Cost is dominated by R2 Class A writes (74% of total). Worker CPU time is effectively free because the executor streams fetch → R2.put without buffering — the average item spent 14 ms of CPU across 4.9 s of wall time (0.29% duty cycle).
The cheapest way to describe the finding: back-catalogue import costs us roughly 1/100th of a US cent per 23-episode podcast, or ~$0.003 for a 500-episode show. R2 storage for the audio itself is additional and ongoing, tracked separately.
Context
The RSS back-catalogue import mirrors every episode from a source feed into our R2 bucket and creates episodes rows. The path involves:
- The main app (Pages Functions) for parse + preview + confirm — see
src/api/routes/imports/index.ts. - The
podcasterplus-podcast-import-executorWorker — queue consumer, one message per episode. See Workers → Import Executor andworkers/podcast-import-executor/. - The
podcasterplus-rss-feedWorker — receives onerss-invalidationmessage when the import completes.
We wanted to establish a defensible per-episode cost before marketing makes any "unlimited back catalogue imports" type claim, and before we scale beyond a handful of users.
Test setup
Podcast under test
- Podcast ID
f92a62fb-9b96-44ec-ae9a-c8df4940eeaf(slugpodcasterplus-remove, name show.fm REMOVE) hosting_type = 'podcasterplus', fresh podcast with 0 existing episodes- 0 prior
podcast_importsrows for this podcast
Source feed
https://feeds.hubhopper.com/59d8b2b51191ebec11774de3bb97ee15.rss- 23 episodes, mean audio size 5,259,263 bytes (~5.0 MB per item), total 115.4 MB
- No chapters, no transcripts, no Podcast 2.0
<podcast:locked>tag - All 23 GUIDs were new to the target podcast (no pre-existing dedupe to muddy the measurement)
Worker configuration From workers/podcast-import-executor/wrangler.toml:
[[queues.consumers]]
queue = "podcast-imports"
max_batch_size = 1
max_concurrency = 4
max_retries = 5
dead_letter_queue = "podcast-imports-dlq"
[limits]
cpu_ms = 10000
[observability]
[observability.logs]
enabled = true
head_sampling_rate = 1
invocation_logs = trueMonitoring
wrangler tail --format jsonon both workers, captured to NDJSON- Supabase MCP polled
podcast_imports+import_itemsat intervals to track counters - Start and end times come from the DB (
started_at,completed_at); per-invocation times come from Cloudflare'swallTime/cpuTimefields in the tail
Evidence
Database outcome
Query at completion:
SELECT status, total_items, completed_count, failed_count, skipped_count,
EXTRACT(EPOCH FROM (completed_at - started_at))::numeric(10,2) AS wall_seconds
FROM podcast_imports
WHERE podcast_id = 'f92a62fb-9b96-44ec-ae9a-c8df4940eeaf'
ORDER BY created_at DESC LIMIT 1;| status | total_items | completed | failed | skipped | wall_seconds |
|---|---|---|---|---|---|
completed | 23 | 23 | 0 | 0 | 50.13 |
SELECT status, count(*) AS n,
SUM(audio_bytes)::bigint AS total_bytes,
ROUND(AVG(audio_bytes))::bigint AS avg_bytes,
SUM(attempts) AS total_attempts
FROM import_items
WHERE podcast_import_id = '<import-id>'
GROUP BY status;| status | n | total_bytes | avg_bytes | total_attempts |
|---|---|---|---|---|
completed | 23 | 120,963,057 | 5,259,263 | 23 |
Attempts = 23 (one attempt per item) confirms zero queue retries.
Executor worker — per-invocation
Parsed from /tmp/import-baseline/executor.ndjson (23 invocations, all outcome: ok):
| Metric | min | median | p95 | max | sum |
|---|---|---|---|---|---|
wallTime (ms) | 4,075 | 4,865 | 5,824 | 8,061 | 116,984 |
cpuTime (ms) | 10 | 14 | 18 | 39 | 342 |
| CPU / wall ratio | 0.29% |
No exceptions. No exceededCpu. No exceededMemory. Zero DLQ messages.
RSS feed worker — per-invocation
One invocation, for the import-complete invalidation:
| Metric | Value |
|---|---|
| Invocations | 1 |
wallTime | 241 ms |
cpuTime | 0 ms |
| Events logged | invalidation_received, KV delete, cache_invalidated |
Pipeline diagram
Cost breakdown
Unit prices come from Cloudflare's published rates on the Workers Paid plan. We use the formula measured_units × price with no rounding until the final row.
23-episode observed cost
| Resource | Units | Unit price | Cost |
|---|---|---|---|
| Worker invocations (executor + rss-feed + API route calls) | ~24 | $0.30 / M | $0.0000072 |
| CPU time (all Workers) | 342 ms = 0.044 GB-s @ 128 MB | $0.02 / M GB-s | <$0.0000000 |
| R2 Class A PUT (audio) | 23 | $4.50 / M | $0.0001035 |
| Queue operations (send + consume + ack) | 72 | $0.40 / M | $0.0000288 |
| Hyperdrive connections | — | free | $0 |
| R2 ingress from source host | — | free (CF doesn't bill inbound) | $0 |
| R2 → Workers egress | — | free within CF network | $0 |
| Total | $0.000140 |
Unit-cost attribution
500-episode linear projection
| Resource | Units | Cost |
|---|---|---|
| Worker invocations | ~501 | $0.000150 |
| CPU time | ~7.4 s | <$0.00000002 |
| R2 Class A PUT | ~500 | $0.002250 |
| Queue operations | ~1,503 | $0.000601 |
| Total | $0.003002 |
Caveat on wall-clock at 500 episodes. Cost extrapolates linearly; wall time does not. At max_concurrency = 4 and ~5.09 s per item, 500 items takes roughly 10.6 minutes (vs. 50.13 s for 23). Email-on-completion UX matters because users will close the tab long before the job finishes.
What this does NOT include
- R2 storage — ongoing at $0.015 per GB-month. 115 MB = ~$0.0017/month for this feed; 2.5 GB (500 eps) = ~$0.038/month.
- Supabase usage — Postgres compute and egress. Negligible for this workload (23 INSERTs); already included in our Supabase tier.
- Bandwidth to listeners — zero on the Cloudflare side (R2 egress is free); listeners are the big-cost unknown once a podcast is live.
- Resend transactional emails — the "import complete" email is one Resend send per import; included in our plan.
Anomalies and flags
Observability gap (non-cost)
The executor's log output via wrangler tail only surfaced one event per invocation (import_item_ack). The expected earlier-phase events (import_item_claimed, r2_put_complete, episode_inserted, finalise_if_last_item_*) were not captured.
Two possibilities to investigate:
- Those phases log at a level that
wrangler taildefaults filter, or - They're not instrumented at all — only the final ack fires.
This is purely an observability issue, not a cost one. Total R2 PUT count is inferred (= 23 audio PUTs, matches item count) rather than verified from logs. If we later ingest chapters / transcripts / artwork, the per-item PUT count will grow and we'll want per-phase visibility to spot regressions.
See workers/podcast-import-executor/src/processItem.ts for the executor source. Recommendation: add structured console.log({ event, import_id, item_id, phase_ms }) at each phase boundary. CPU-cost delta is negligible (currently 14 ms median per invocation — adding 5–10 log lines is measurable-but-free).
Zero retries in the baseline
The total_attempts column summed to exactly 23 — one attempt per item — so this baseline does not characterise retry behaviour. A future study should include a run with a flaky source host (simulated via mid-stream abort) to measure the cost of the 5-retry exponential-backoff policy.
max_concurrency sensitivity
This run used the default max_concurrency = 4. Under max_concurrency = 8 (or higher), wall time would halve but per-source-host politeness suffers — we'd risk tripping rate limits on small podcast hosts. The user-visible tradeoff is worth characterising separately before we consider tuning it.
Reproducing this benchmark
Pick a target podcast with
hosting_type = 'podcasterplus'and zero prior imports.Pick a public RSS feed with a known episode count (Hubhopper, Transistor, Libsyn all work).
In two terminals, capture the workers:
bash# Terminal 1 cd workers/podcast-import-executor && npx wrangler tail --format json > /tmp/executor.ndjson # Terminal 2 cd workers/rss-feed && npx wrangler tail --format json > /tmp/rss-feed.ndjsonTrigger the import through the UI (
/p/{slug}/import/new?feed={url}).While it runs, watch DB counters:
sqlSELECT status, total_items, completed_count, failed_count, skipped_count, EXTRACT(EPOCH FROM (COALESCE(completed_at, NOW()) - started_at))::int AS elapsed_s FROM podcast_imports WHERE podcast_id = '{uuid}' ORDER BY created_at DESC LIMIT 1;When status flips to
completed, stop the tails and parse:bash# Files are concatenated pretty-printed JSON objects (not NDJSON proper). # Slurp with jq: jq -s 'map(select(.scriptName == "podcasterplus-podcast-import-executor")) | {count: length, wall_sum: map(.wallTime) | add, cpu_sum: map(.cpuTime) | add, cpu_max: map(.cpuTime) | max}' /tmp/executor.ndjsonMultiply measured unit counts by the Cloudflare price list. Log the result here.
Supporting artifacts for this particular run were retained under /tmp/import-baseline/ at run time; they're not checked in, so re-running the benchmark means capturing fresh files.
Related documentation
- Podcast Import Executor worker — worker architecture, queue bindings, failure modes.
- Imports API — producer for the
podcast-importsqueue; start/verify/preview/confirm/cancel/retry/resume. - Cloudflare service integration — R2, Workers, Queues, Hyperdrive bindings used across the platform.
src/api/routes/imports/index.ts— API routes (parse, preview, confirm, cancel, resume, recheck-feed).workers/podcast-import-executor/src/processItem.ts— per-item pipeline (HEAD → fetch → R2 PUT → INSERT → finalize).
Captured 2026-04-22 · next review due when Cloudflare pricing changes or when we add chapters/transcripts/artwork mirroring to the per-item path.