If your RunPod serverless worker logs say done but your client raises unexpected handler return type: <class 'NoneType'>, you've hit RunPod's bidirectional 20 MB payload cap on /runsync. The handler succeeded. The gateway dropped the response on the way back because the payload was too large.
The fix is two steps. Set transport: "s3" on the job, and configure four env vars on the endpoint pointing at a Cloudflare R2 bucket. The worker uploads the result to R2 and returns a small presigned URL. Your client downloads from R2 directly, so the archive does not cross the gateway response.
I hit this on an 82-page Cyrillic fiscal report (30 MB input, ~25 MB output with embedded images) running my open-source mineru-runpod template. Two retries via transport: "inline" and transport: "tarball_b64" failed the same way. R2 mode worked first try. The rest of this post is the symptom, the env-var recipe, and the limits worth knowing.
Why does my RunPod worker return NoneType after a successful parse?
The worker handler completed and returned a valid dict. RunPod's runtime then tried to POST that result back to RunPod's API via /job-done, and the API returned HTTP 400 because the payload exceeded ~20 MB. The result was discarded. The SDK saw no output, returned None to the client, and the client wrapper raised the NoneType error.
The worker logs make the chain explicit:
[mineru-worker] done: elapsed=91.77s phase_ms={'fetch_input': 972, 'mineru_parse': 90789, 'package': 66}
{"requestId": "sync-fdcd03cd-...", "message": "Failed to return job results. | 400, message='Bad Request',
url='https://api.runpod.ai/v2/<endpoint>/job-done/<worker>/sync-fdcd03cd-...?gpu=NVIDIA+RTX+A5000&isStream=false'"}The first line shows the handler finished cleanly: 82 pages parsed in 91.8 s on the worker (this test ran on A5000; on the current 4090 default the warm parse is 2–3× faster). The second line shows the gateway rejecting the result. The handler already returned and never knows the rejection happened. The SDK sees the discarded result and returns None to your code.
If you see this NoneType error on a small doc, the diagnosis is different (worker OOM, crash, timeout). On a multi-page parse that the worker logs as done, the answer is almost always the 20 MB cap.
What is RunPod's /runsync response payload limit?
RunPod's /runsync gateway caps payloads at roughly 20 MB in both directions. The request cap affects file_b64 inline uploads. The response cap affects what the worker can return. Both are independent of execution time and memory budget. A fast, successful parse can hit the response cap simply by producing a large output.
| Direction | Limit | What triggers it |
|---|---|---|
| Request → gateway → worker | 20 MB on /runsync; 10 MB on /run | file_b64 for large PDFs; base64 leaves roughly 15 MB or 7.5 MB for raw bytes |
| Worker → gateway → client | ~20 MB | Multi-page parse outputs with embedded images |
The request cap is in RunPod's docs and widely discussed. The response cap is mentioned only in passing. I found three open issues on the runpod-workers repos where other users hit the same symptom and didn't realise what it was, so this post is partly to make that searchable.
Practical threshold for mineru-runpod: pure-text PDFs are fine for longer. Image-heavy PDFs with embedded raster output hit the response cap around 50–80 pages on inline or tarball_b64 transport.
Does transport: "tarball_b64" get around the 20 MB cap?
No. transport: "tarball_b64" gzips the output into a single .tar.gz before base64-encoding it. Gzip compresses the JSON and Markdown text well, but the page images inside the tarball are already raster bytes (PNG, JPEG) and barely compress further. Multi-page parses with embedded images keep the tarball over 20 MB.
I confirmed this on the same 82-page PDF. Same 400 from /job-done. Same NoneType in the client. Both inline and tarball_b64 route through the gateway response, so both inherit the cap. Only transport: "s3" avoids it because the worker uploads out of band.
How do I configure Cloudflare R2 to bypass the RunPod response cap?
Set transport: "s3" in the job input, then add four env vars on the RunPod endpoint pointing at a Cloudflare R2 bucket. The worker uploads the gzipped tarball directly to R2 and returns a small presigned URL (~1 h TTL). Your client downloads from R2.
The job input changes one field:
{
"input": {
"file_url": "https://example.com/big.pdf",
"transport": "s3"
}
}The four env vars go on the endpoint (not the template — they're secrets):
| Env var | Cloudflare R2 value |
|---|---|
BUCKET_ENDPOINT_URL | https://<account-id>.r2.cloudflarestorage.com |
BUCKET_NAME | your bucket name |
BUCKET_ACCESS_KEY_ID | R2 API token access key |
BUCKET_SECRET_ACCESS_KEY | R2 API token secret |
BUCKET_REGION (optional) | auto |
You generate the access key pair in the Cloudflare dashboard: R2 → Manage R2 API Tokens → Create API Token → Object Read & Write scoped to the bucket. Restart or redeploy active workers after saving the variables, then test with one small document.
Why pick Cloudflare R2 over AWS S3 for RunPod output storage?
R2 is S3-compatible and does not charge for direct Internet egress. Its current Standard free allowance includes 10 GB-month of storage, 1 million Class A operations, and 10 million Class B operations each month. Check R2 pricing before relying on those numbers.
A back-of-envelope month for the workload I tested:
- 1,000 multi-page parses, average output 8 MB → 8 GB stored then deleted
- 1,000 worker→bucket uploads + 1,000 client→bucket downloads = 2,000 ops
- Storage: free (under 10 GB). Egress: free (R2 doesn't bill egress). Ops: free (well under 1M Class A).
S3 can still make sense when your pipeline already uses AWS IAM, lifecycle rules, and regional data services. Compare current provider prices and your actual transfer path rather than assuming one bucket is universally cheaper.
What does the parse flow look like end-to-end with transport: "s3"?
The worker fetches the input PDF, runs MinerU, gzips the outputs into a tarball, uploads to R2 via the configured BUCKET_* env vars, and returns a small JSON response with tarball_url, tarball_url_expires_in (3600 s), and bucket_key. Your client follows the URL and extracts the tarball locally. Only the small JSON result crosses RunPod's response path.
Concrete numbers from the 82-page test (on A5000; current default is 4090):
result = client.parse_document(
file_url="https://pub-....r2.dev/report.pdf",
backend="vlm-auto-engine",
transport="s3",
)
entry = client.first(result)
# entry["tarball_url"] -> presigned R2 URL, valid ~1 h
# entry["tarball_url_expires_in"] -> 3600
# entry["bucket_key"] -> "report-<uuid>.tar.gz"
client.save_s3_tarball(result, "./out/")
# downloads + extracts -> out/report.md, out/report_content_list.json, out/images/, ...End-to-end wall-clock: 211 s for an 82-page doc on a cold worker. Breakdown: ~112 s before MinerU started parsing (worker boot + warmup), ~92 s warm parsing (1.1 s/page on A5000), ~11 s gzip and upload to R2 (the package phase). The extracted output: 313 KB Markdown plus structured JSON plus per-page images. Roughly 3.5 minutes for a document that previously couldn't return its output at all.
The cold-start portion is a separate concern from the response cap. The FlashBoot mechanism investigation covers why the ~112 s exists, how the boot-time warmup interacts with RunPod's snapshot system, and when subsequent cold starts are much faster.
What should I watch out for with the R2 bridge?
Four things the docs don't say loudly. The presigned URL TTL is 60 minutes. R2 doesn't auto-clean uploaded objects. One bucket can serve input and output. Async submission does not avoid the handler-output cap.
- Presigned URL TTL is 60 minutes. Download promptly. If a fork needs a longer window, change
S3_PRESIGN_TTL_SECONDSinworker/package.pyand redeploy. - R2 doesn't auto-clean uploaded objects. Add an R2 lifecycle rule (e.g. delete after 7 days) so your output bucket doesn't grow forever.
- One R2 bucket can serve input and output. Upload PDFs under an input prefix, pass a short-lived presigned GET URL as
file_url, and let the worker write outputs underBUCKET_PREFIX. - Async does not fix an oversized result.
/runreturns a job id, but the worker still has to post the completed output to RunPod. Keep large results out of that path withtransport: "s3". The inbound/runrequest limit is 10 MB, lower than/runsync's 20 MB.
FAQ
How do I get the R2 access key for BUCKET_ACCESS_KEY_ID and BUCKET_SECRET_ACCESS_KEY?
In the Cloudflare dashboard: R2 → Manage R2 API Tokens → Create API Token. Set permissions to "Object Read & Write" scoped to the specific bucket. Cloudflare shows the access key ID and secret access key once; copy both into your RunPod endpoint env vars immediately. The secret isn't retrievable later.
Does the presigned URL expire?
Yes. The default TTL is 3600 seconds (one hour). If a downstream job may start later, download and persist the archive promptly or change S3_PRESIGN_TTL_SECONDS in a fork before redeploying.
Can I reuse the same R2 bucket for input and output?
Yes. The worker does not care about the bucket layout. Upload input PDFs under inputs/ and the worker writes output as <basename>-<uuid>.tar.gz. Add BUCKET_PREFIX if you want a separate output prefix.
What if I can't set up R2? Is there a fallback?
Page chunking. Split the parse with start_page and end_page into segments small enough that each output tarball stays under 20 MB, then concatenate the .md files client-side. Slower (you may pay multiple cold starts if the worker scales to zero between calls) and you handle joining yourself, but no infra changes needed.
Is the 20 MB cap on /run too, or only /runsync?
Async /run does not solve the response-size problem. It returns a job id first, but the worker still has to submit the completed handler output to RunPod before /status can return it. Use S3 mode for a large result. On the inbound side, /run allows 10 MB for the whole request while /runsync allows 20 MB.
Does using transport: "s3" add to cold-start time?
No. The S3 upload happens at the end of the parse, not the beginning. The handler's package phase grew from ~95 ms (in-memory tarball) to ~11 s (gzip + upload to R2) on an 82-page job, but cold-start is unchanged. The S3 mode adds a small constant to warm-job latency, not a multiplier.
How big can the R2-uploaded tarball be?
The gateway response cap no longer applies, but output is not unlimited. The worker builds the complete compressed archive in memory and uploads it with one put_object call. Practical limits are worker RAM, temporary disk, execution timeout, and the provider's single-upload limit. R2 currently caps a single-part upload just below 5 GiB.
Does R2 work for input PDFs too, or only output?
Both. The worker accepts a presigned R2 GET URL or a production custom-domain URL as file_url, avoiding the inbound file_b64 cap. The worker still enforces its 200 MB URL-download limit.
Where to next
If you expect image-heavy output near the gateway limit, configure transport: "s3" before production traffic. It takes four endpoint variables plus a bucket lifecycle policy.
If you're new to the template, the getting-started guide walks through the full deploy in about ten minutes. For the cold-start side of the picture (separate from the response cap covered here), see the FlashBoot mechanism investigation. For GPU sizing, Choosing a GPU covers when the default ADA_24 (RTX 4090) is enough and when to opt up.
If this saved you time, the easiest way to say thanks is signing up for RunPod through this link. Star the repo on GitHub for updates.
Disclosure: RunPod links in this post use a referral code that credits me at no cost to you. The post would read the same without it.