Skip to content

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

Metric23-episode import500-episode import (projected)
Wall-clock duration50.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 R2120,963,057 (~115 MB)~2.5 GB
Retries / failures0

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:

  1. The main app (Pages Functions) for parse + preview + confirm — see src/api/routes/imports/index.ts.
  2. The podcasterplus-podcast-import-executor Worker — queue consumer, one message per episode. See Workers → Import Executor and workers/podcast-import-executor/.
  3. The podcasterplus-rss-feed Worker — receives one rss-invalidation message 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 (slug podcasterplus-remove, name show.fm REMOVE)
  • hosting_type = 'podcasterplus', fresh podcast with 0 existing episodes
  • 0 prior podcast_imports rows 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:

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 = true

Monitoring

  • wrangler tail --format json on both workers, captured to NDJSON
  • Supabase MCP polled podcast_imports + import_items at intervals to track counters
  • Start and end times come from the DB (started_at, completed_at); per-invocation times come from Cloudflare's wallTime / cpuTime fields in the tail

Evidence

Database outcome

Query at completion:

sql
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;
statustotal_itemscompletedfailedskippedwall_seconds
completed23230050.13
sql
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;
statusntotal_bytesavg_bytestotal_attempts
completed23120,963,0575,259,26323

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):

Metricminmedianp95maxsum
wallTime (ms)4,0754,8655,8248,061116,984
cpuTime (ms)10141839342
CPU / wall ratio0.29%

No exceptions. No exceededCpu. No exceededMemory. Zero DLQ messages.

RSS feed worker — per-invocation

One invocation, for the import-complete invalidation:

MetricValue
Invocations1
wallTime241 ms
cpuTime0 ms
Events loggedinvalidation_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

ResourceUnitsUnit priceCost
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 connectionsfree$0
R2 ingress from source hostfree (CF doesn't bill inbound)$0
R2 → Workers egressfree within CF network$0
Total$0.000140

Unit-cost attribution

500-episode linear projection

ResourceUnitsCost
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:

  1. Those phases log at a level that wrangler tail defaults filter, or
  2. 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

  1. Pick a target podcast with hosting_type = 'podcasterplus' and zero prior imports.

  2. Pick a public RSS feed with a known episode count (Hubhopper, Transistor, Libsyn all work).

  3. 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.ndjson
  4. Trigger the import through the UI (/p/{slug}/import/new?feed={url}).

  5. While it runs, watch DB counters:

    sql
    SELECT 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;
  6. 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.ndjson
  7. Multiply 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.


  • Podcast Import Executor worker — worker architecture, queue bindings, failure modes.
  • Imports API — producer for the podcast-imports queue; 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.

Internal documentation - Not for public distribution