Step-by-Step: Chunked Upload for Telegram API

1. Why Chunked Upload Exists and How It Evolved
Telegram Bot API has always enforced an upper limit on the payload you can push in one HTTP request. Until 2023 the ceiling was 50 MB; after the 7.0 protocol refresh (April-2023) the documentation quietly added the word “recommended” in front of 20 MB chunks, and by Bot API 7.8 (November-2025) the server-side streamer will actually reject a single-shot upload > 50 MB with 413 Request Entity Too Large. Chunked upload—officially “upload.saveFilePart” inside the MTProto layer—was therefore exposed to bots so they could join the same resumable path that Telegram Desktop uses for human users. The result is threefold: fewer mid-upload timeouts, the ability to show a progress bar, and a de-facto file cap of 2 000 MB (2 GB) that is simply not reachable through the monolithic approach.
From a metrics perspective, the change is measurable. In a sample of 1 500 uploads collected by the community project “TgPerfLog” (Sept–Oct 2025, mixed 4G/5G/Wi-Fi), chunked transfers ≥ 90 MB succeeded 96.4 % of the time versus 56.8 % for legacy single-shot. Median upload time dropped 18 % because the bot could parallelise two chunks while the previous two were still being acknowledged. These numbers are empirical, but the repo contains open scripts so you can rerun the benchmark against your own bot token and publish the diff.
The evolutionary path is worth emphasising: chunked upload began as an internal MTProto mechanism for human clients, graduated to Telegram Desktop in 2018, and finally surfaced in the Bot API once mobile networks became the dominant delivery path. Each hop added stricter size checks, but also richer telemetry for the client. Bots now inherit the same acknowledgement pipeline, which means a failure at part 147 is reported exactly like a failure at part 1—no more opaque “upload failed” black boxes.
2. Functional Boundaries You Must Respect
2.1 File Size Floor and Ceiling
You are obliged to chunk only if the file is > 50 MB. Anything smaller is accepted in one go and, more importantly, will not receive a file_part_* identifier from the server, so you cannot “chunk for fun” on a 5 MB picture—the server will treat every chunk as an independent 5 MB file and you will end up with orphan parts that count against the cache quota.
2.2 Part Size Quantum
Each part must be exactly 512 KB × N where N ∈ [1, 39] for the 20 MB default. Picking 1 MB (N=2) is the community sweet spot because it keeps the request under 30 s even on a 500 kbps uplink while still staying under the 20 MB “recommended” guardrail. If you are on satellite back-haul (RTT 600–800 ms) you may drop to 256 KB to fit three in-flight requests inside the congestion window; the server still accepts it because 256 KB is a legal sub-multiple.
Empirically, part sizes above 4 MB (N=8) show diminishing returns: the extra bytes save only one round-trip per 100 MB, yet the probability of hitting a FLOOD_WAIT_X doubles because the larger payload keeps the connection busy longer. In contrast, sizes below 256 KB (N=0.5) are rejected outright, so 256 KB is the practical minimum even on lossy links.
3. Step-by-Step Implementation (Python 3.11 Example)
The following snippet is self-contained, uses only the standard library plus aiohttp, and deliberately omits third-party wrappers so you can see the raw JSON. The same flow is reproducible in any language that can speak HTTPS POST.
import asyncio, aiohttp, hashlib, os, math
BOT_TOKEN = os.getenv("BOT_TOKEN")
PART_SIZE = 1024 * 1024 # 1 MB
UPLOAD_URL = f"https://api.telegram.org/file/bot{BOT_TOKEN}/"
async def upload_chunked(path: str):
file_size = os.path.getsize(path)
if file_size <= 50 * 1024 * 1024:
return await upload_simple(path) # fallback not shown
part_count = math.ceil(file_size / PART_SIZE)
sha256 = hashlib.sha256()
async with aiohttp.ClientSession() as session:
file_id = None
with open(path, "rb") as fh:
for i in range(part_count):
chunk = fh.read(PART_SIZE)
sha256.update(chunk)
form = aiohttp.FormData()
form.add_field("chat_id", "@my_channel")
form.add_field("part", str(i))
form.add_field("total", str(part_count))
form.add_field("file", chunk, filename=f"part{i}")
async with session.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/uploadSaveFilePart",
data=form) as r:
payload = await r.json()
if not payload["ok"]:
raise RuntimeError(payload)
file_id = payload["result"]["file_id"] # server returns cumulative id
# final commit
await session.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/sendDocument",
data={"chat_id": "@my_channel", "document": file_id, "sha256": sha256.hexdigest()})
return file_id
asyncio.run(upload_chunked("video_1.2GB.mp4"))
The endpointuploadSaveFilePartis documented inside the Bot API 7.8 changelog under “Added resumable uploads”. If you are on 6.x you will receive404 Not Found—upgrade first.
4. Platform-Specific UI Paths for Manual Fallback
Sometimes you need to re-upload a file manually while the bot is offline. Telegram Desktop, Android and iOS all expose the same chunked engine, but the entry points differ:
- Desktop (Win/Mac/Linux 5.6+): Drag file > 100 MB into any chat → progress bar appears immediately → hover the bar → “︙” menu → “Copy file link” gives you the final
file_idyou can paste into your bot’s database. - Android (10.12.0): Attach button → File → pick > 50 MB → long-press the sending message → “Details” → scroll to “Internal file_id”.
- iOS (10.12.1): identical path, but “Details” is hidden behind the (i) icon; Apple’s sandbox forces a temporary copy so you need 2× free space during upload.
If you intend to inject the resulting file_id back into your bot ecosystem, store the integer string exactly as returned; trimming trailing zeros invalidates the reference and triggers FILE_REFERENCE_EXPIRED after 24 h.
Manual fallback is handy during incidents when your cloud function times out but the user still needs the file. By copying the file_id into a /sendDocument call you can re-attach the same blob without re-uploading, saving both bandwidth and quota.
5. Error Handling Matrix
| HTTP code | Bot API message | Root cause | Retry rule |
|---|---|---|---|
| 413 | REQUEST_ENTITY_TOO_LARGE | Part > 20 MB or total > 2 GB | Drop part size by ½, resume from last ack |
| 400 | FILE_PART_EMPTY | Zero-byte payload | Skip part, do not increment counter |
| 420 | FLOOD_WAIT_X | > 20 parts/min per chat | Sleep X seconds, then resume |
| 500 | INTERNAL | DC overload | Exponential back-off, switch DC on 3rd fail |
All retry logic should be idempotent: re-send the same part index with identical bytes. The server uses a content-addressable cache; if the part already exists it replies instantly without burning your quota.
6. A/B Testing Upload Strategy
If your bot serves both Wi-Fi and 3G users, run a dynamic split: detect ASN network type via ip-api.com (free tier 45 req/min) and switch chunk size accordingly. A 2025-09 experiment on a public file-hosting bot (n = 12 847 uploads) showed:
- Wi-Fi cohort (chunk 2 MB): median throughput 8.7 MB/s, 2.1 % timeout
- 3G cohort (chunk 512 KB): median 1.2 MB/s, 3.8 % timeout—versus 11 % when forced to 2 MB
The takeaway: chunk size is not a “set and forget” constant; treat it as a congestion control variable similar to TCP window scaling.
7. Monitoring & Validation Checklist
- Log every
part_index,ack_time,http_codeandbytes_sentto InfluxDB or Prometheus. - Compute
upload_success_rate = ok_parts / total_partsper hour; alert if < 95 %. - Compare SHA-256 of local file with server-returned hash; mismatch means bit-rot or man-in-the-middle.
- Track
FLOOD_WAIT_Xfrequency; if you hit it more than 5 times per 100 uploads, raise part size to reduce request count. - End-to-end test: download the file via
getFileand diff bytes; any delta > 0 is a P0 bug.
8. When NOT to Use Chunked Upload
Despite the reliability gains, there are valid reasons to stay with the simple sendDocument path:
- Your hosting plan bills per outgoing request (some edge functions charge $0.20 per million). A 1 GB file at 1 MB chunks equals 1 000 requests—ten times the cost of one-shot.
- You forward already-hosted files by URL. Telegram will fetch from the origin server; you pay zero egress and get deduplication for free.
- Compliance requires single-shot tamper-evident upload (e-discovery logs). Resumable sessions complicate the audit trail because parts can arrive out of order.
As a rule of thumb, switch to chunked only when the file is both > 50 MB and user-uploaded from an unstable link.
9. Integration With Third-Party Bots (Minimal-Permission Pattern)
Suppose you run a transcription bot that needs the user’s voice file. Instead of asking for blanket “message” read access, use a deep-link:
- User taps
/start→ bot replies with keyboard button “Send voice > 50 MB”. - Button URL:
https://t.me/yourbot?start=chunk - When the voice arrives, the bot already knows the user consented to large-file processing; now it can request
uploadSaveFilePartwithout triggering user suspicion.
Never store the user’s file_id longer than necessary; delete the reference after processing to stay within GDPR “storage limitation” principle.
10. Future-Proofing: What the Changelog Hints
Telegram’s public issue tracker (https://bugs.telegram.org) carries ticket #DPLT-2047 titled “Adaptive chunk size for Bot API”, status “Accepted”. The proposal adds an optional header X-Chuck-Size-Hint: <bytes> that lets the server override the client’s size at runtime. If it ships, you will be able to remove hard-coded constants and simply honour the hint—one more reason to centralise chunk logic in a single helper instead of scattering magic numbers across your codebase.
Until that day, keep your retry logic stateless, your part size configurable via environment variable, and your logs verbose enough to replay any failed session. Chunked upload is no longer a niche optimisation; for any bot that handles video, backups, or generative-AI payloads, it is the only reliable way to move bytes through Telegram’s highway without hitting the 50 MB glass wall.
11. Case Studies
11.1 University Lecture Bot (small scale)
Context: A 3 000-student campus needed nightly 700 MB lecture recordings delivered to a private channel. The bot ran on a free-tier VPS with 1 vCPU and 512 MB RAM.
Implementation: 1 MB chunks, parallel degree 2, gzip pre-compression disabled because the source was already H.264. Upload window was 02:00–04:00 local time to exploit idle campus uplink.
Result: 28 consecutive days without timeout; median upload 11 min 14 s. Only two incidents of FLOOD_WAIT_32 when the campus proxy lost NAT mapping; retry after 32 s succeeded.
Post-mortem: CPU never exceeded 35 %; RAM stayed under 180 MB. The limiting factor was outbound bandwidth, not chunk logic, proving that modest hardware can handle GB-scale uploads if congestion control is tuned.
11.2 Multi-tenant Cloud Backup Bot (large scale)
Context: SaaS offering encrypted backups for 14 k small businesses; file mix 40 % photos, 30 % videos, 30 % ZIP archives. Peak 18 TB/week.
Implementation: Kubernetes job per upload, 2 MB chunks for Wi-Fi ASN, 512 KB for cellular; Prometheus alert on success_rate < 95 %. Canary deployment switched 5 % traffic each day.
Result: After 60 days, chunked cohort showed 97.8 % success vs. 62 % legacy; customer support tickets for “upload stuck” dropped 81 %. Egress cost increased by $120/month (extra 11 M requests) but support savings outweighed spend 4×.
Post-mortem: Needed dedicated Redis lease per file to prevent two pods from uploading the same chunk; without it, duplicate parts wasted 3 % of upstream bandwidth. Final fix: deterministic pod-hash on file path.
12. Monitoring & Rollback Runbook
12.1 Abnormal Signals
success_rate < 95 %for > 5 min- P99
ack_timejumps above 45 s FLOOD_WAIT_X> 10 % of total requests- Shard DC1 5xx rate > 2 % while DC2 < 0.5 %
12.2 Localisation Steps
- Open Influx dashboard, group by
dc, part_size, asn. - If errors cluster on one DC, switch
upload_urlto next DC IP (rotate by +1). - If errors correlate with 2 MB chunk on 3G ASN, override to 512 KB via feature flag.
- Check SHA-256 mismatch log; if > 0, pause new uploads, investigate MITM or memory corruption.
12.3 Rollback Command
kubectl patch deployment upload-bot -p '{"spec":{"template":{"spec":{"containers":[{"name":"bot","env":[{"name":"CHUNK_STRATEGY","value":"single"}]}]}}}}'
# single = force fallback to 50 MB monolithic
12.4 Rollback Verification
After rollback, watch success_rate for 10 min; if it returns above 98 %, keep legacy path and schedule root-cause review. If still below, escalate to Telegram platform team with trace-id.
12.5 Quarterly Drill List
- Simulate DC outage by black-holing one IP block.
- Inject 5 % random packet loss via tc-netem; assert success_rate ≥ 93 %.
- Trigger
FLOOD_WAIT_60artificially with high RPS; measure auto-back-off curve. - Restore from cold backup; confirm
file_idstill downloadable after 24 h.
13. FAQ
- Q: Can I mix chunked and single-shot in the same bot?
- A: Yes—decide at runtime by file size. Ensure shared state does not leak
file_part_* ids into single-shot code path.
- Q: Will the server compress my chunks?
- A: No; Telegram stores raw bytes. Pre-compress if your payload is highly compressible and CPU is cheaper than bandwidth.
- Q: Is there a rate limit per bot token?
- A: Official figure is undocumented; empirical observation shows ≈ 20 parts/min per chat before
FLOOD_WAIT_X.
- Q: Can I upload from a serverless function?
- A: Yes, but keep execution time below provider limit (e.g., 15 min for AWS Lambda). Use 2 MB chunks and parallel degree 1 to stay within memory.
- Q: Do parts expire?
- A: Parts live 24 h after last ack; final
file_id lives until last reference is deleted.
- Q: Why does SHA-256 mismatch occur?
- A: Either memory corruption during read, or transparent proxy re-encoding the multipart boundary. Always open file in binary mode and disable antivirus on-access scan for upload directory.
- Q: Can users see the progress bar?
- A: No; bots cannot update the circular progress that human clients show. You can send a text message with percentage as a workaround.
- Q: Is chunked upload GDPR compliant?
- A: The mechanism is neutral; compliance depends on your retention policy. Delete
file_id and parts when no longer needed.
- Q: Does Telegram deduplicate parts?
- A: Parts are cached by content hash; identical bytes are stored once. Deduplication is internal and not exposed to the client.
- Q: Can I cancel an in-flight upload?
- A: Stop sending remaining parts; unfinished sessions expire after 24 h. There is no explicit “abort” call.
- Q: What happens if I reorder parts?
- A: Server rejects out-of-order indices with
FILE_PART_INVALID. Always send ascending indices.
file_part_* ids into single-shot code path.FLOOD_WAIT_X.file_id lives until last reference is deleted.file_id and parts when no longer needed.FILE_PART_INVALID. Always send ascending indices.14. Terminology Quick Reference
| Term | Meaning | First used in |
|---|---|---|
| Part | A 512 KB × N chunk of the file | Section 2.2 |
| file_id | Server-side handle to re-use the file | Section 3 |
| FLOOD_WAIT_X | Rate-limit pause of X seconds | Section 5 |
| MTProto | Telegram’s native transport protocol | Section 1 |
| DC | Data-centre shard (1-5) | Section 12 |
| TgPerfLog | Community telemetry repo | Section 1 |
| uploadSaveFilePart | Bot API endpoint for chunked upload | Section 3 |
| file_part_* | Internal identifier for a chunk | Section 2.1 |
| X-Chuck-Size-Hint | Proposed adaptive header | Section 10 |
| ASN | Autonomous System Number (network carrier) | Section 6 |
| diff bytes | Binary comparison after download | Section 7 |
| P0 bug | Highest priority defect | Section 7 |
| Prometheus | Open-source metrics collector | Section 7 |
| InfluxDB | Time-series database | Section 7 |
| canary deployment | Progressive traffic shift for testing | Section 11.2 |
| content-addressable cache | Storage keyed by content hash | Section 5 |
15. Risk & Boundary Matrix
| Scenario | Risk | Mitigation / Alternative |
|---|---|---|
| Serverless timeout | Function killed after 15 min | Use 2 MB chunks, parallel 1, or move to VM |
| Per-request billing | 1 000 chunks → 1 000 billed calls | Stay with single-shot ≤ 50 MB, or negotiate bulk tariff |
| GDPR audit | Parts scattered in cache | Document retention ≤ 24 h, provide deletion API |
| iOS sandbox | 2× disk space needed | Warn user before upload; no mitigation on client side |
| Corrupted chunk | SHA-256 mismatch | Re-read from disk, retry idempotent part upload |
| Orphan parts | Count against hidden cache quota | Never chunk ≤ 50 MB; always finish session |
| File > 2 GB | Hard rejected by server | Split into multi-volume ZIP or host externally |
16. Future Outlook
Looking ahead, the most probable evolution is server-driven chunk sizing. Once header X-Chuck-Size-Hint ships, client code will shrink to a simple loop that honours dynamic hints. Longer-term, experimental MTProto 3.0 may replace HTTPS for bots, bringing zero-copy streaming and native compression. Until those land, the patterns in this article remain the stable baseline: respect 50 MB threshold, keep part size configurable, log everything, and always validate the SHA-256 before declaring victory.