Most multimodal chat APIs accept an image two ways:
- Inline, as a base64
data:URL embedded in the request body. - As a link, an HTTPS URL the provider fetches on its own.
Both messages have the same shape; only the url value differs:
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."}}
]
}{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://my-bucket.s3.amazonaws.com/photo.png?X-Amz-Signature=..."}}
]
}The first embeds the whole file as text, so the model already has the bytes. The second is a few hundred bytes long no matter how big the image is, and the provider has to fetch the URL before it can answer.
Apps that keep user images in object storage (S3, R2, GCS) usually pick the second option and sign a temporary URL per request. That choice looks free. It is not obviously free: someone else's server now has to reach out and download the image before it can answer, and every provider treats prompt caching differently once a URL is involved instead of raw bytes.
To our knowledge, no public evaluation has carefully measured this exact question end to end. Every provider documents its own prompt-caching mechanism, but none of that documentation is clear on how caching interacts with image delivery specifically.
We measured it directly: three ways to deliver the same image (inline data:, presigned re-signed every turn, presigned signed once and reused), across five current small models, with a timer that never touches the image bytes before the call.
Across all five models, inline data: URLs reach first token faster than a presigned URL, by 104 ms on the smallest gap (Mistral Small) to 3.76 s on the largest (Grok 4.3). The cached-token count each provider reports barely moves between formats.
Setup
Five models, each provider's smallest or newest small-tier model with vision, each run at its lowest available reasoning setting (no single setting works across all five providers, so "lowest available" is the best common floor, not a true zero):
| Model | Provider | Model ID | Reasoning setting |
|---|---|---|---|
| Claude Haiku 4.5 | Anthropic | claude-haiku-4-5 | off, no thinking param |
| GPT-5.6 Luna | OpenAI | gpt-5.6-luna | reasoning_effort="none" |
| Gemini Flash 3.8 | gemini-3.8-flash | thinking_level="low" (floor, cannot disable) | |
| Grok 4.3 | xAI | grok-4.3 | reasoning_effort="low" (floor, cannot disable) |
| Mistral Small | Mistral | mistral-small-2603 | reasoning_effort="none" |
Because reasoning cannot be pinned to the same setting everywhere, latency is compared only within a model, across its own three arms, never across models.
Each model runs 8 series of up to 10 growing-conversation turns (7 for Mistral, see below), replaying every prior turn's image alongside the new one, so cache should climb with conversation depth. Each turn asks the model to transcribe a synthetic 1920x1080 PNG of random word-salad text, scored with RapidFuzz similarity against the known target.
Each new turn resends the whole conversation so far, not just the new image, so both the payload size and the cache grow with turn:
Three delivery arms:
data:: the PNG bytes, base64-encoded, inline in the request.- presigned, rotating: an S3 presigned GET URL, freshly re-signed every time that image is sent, including every later replay of an earlier turn's image.
- presigned, fixed: an S3 presigned GET URL, signed once per object (long TTL) and reused as the same string for the rest of the run.
That distinction carries a structural cost either way:
data:needs no extra infrastructure, but every turn resends the full base64 bytes of every prior image in the thread, so the request body itself grows with conversation depth.- presigned (either arm) keeps the resent history light, a URL is a few hundred bytes, an image is not, at the cost of running an object-storage tier, with no guarantee the provider skips re-fetching an image it already fetched on an earlier turn.
ttft_ms, time to the first visible token, is the primary metric, not wall-clock total time: a hidden reasoning phase would otherwise inflate total_time_ms unevenly across arms if reasoning length happened to correlate with delivery method. Every system prompt carries a 32-character random nonce, unique per (run, arm), as early as possible in the prompt, so no arm's cache can be seeded by another arm's identical prefix. Two error classes are logged and retried without contaminating the timer: HTTP 429 rate limits, and HTTP 400 "the provider's own fetch of the image timed out." Both retries reset the clock instead of accumulating it.
Mistral Small hard-rejects a request once the running history plus the new turn exceeds 8 images, so its threads stop after turn 6 (7 images), one turn earlier than the other four models' 10-turn threads. Every Mistral number in this post, and every Mistral bar or line below, covers a shallower conversation than the other four models. Do not read a pooled Mistral total next to a pooled non-Mistral total as the same conversation depth.
Results
Transcription quality is not the bottleneck
Similarity to the target text is exactly 1.0 on every one of the 1,128 rows, across all five models and three arms. Delivery format does not measurably change what the model reads off the image.
Inline data URLs reach first token faster, on every model
Paired bootstrap deltas, matched by (series, turn) within a model, median of 2,000 resamples, all with a 95% confidence interval that excludes zero:
| Model | data vs rotating (ms) | data vs fixed (ms) |
|---|---|---|
| Claude Haiku 4.5 | -934 [-976, -888] | -941 [-970, -894] |
| GPT-5.6 Luna | -593 [-656, -510] | -349 [-421, -288] |
| Gemini Flash 3.8 | -1110 [-1690, -702] | -1111 [-1619, -813] |
| Grok 4.3 | -3086 [-3800, -2076] | -3763 [-4484, -2974] |
| Mistral Small (7 turns) | -104 [-159, -70] | -117 [-144, -92] |
Negative means data: is faster. All ten comparisons agree on direction and exclude zero. The size of the gap is not the same model to model: 104 ms for Mistral Small, 3.76 s for Grok 4.3.
Rotating versus fixed presigned URLs mostly does not matter
Once re-signing every turn is actually happening (see Setup), rotating and fixed presigned URLs post the same ttft_ms on four of five models: Claude Haiku 4.5, Gemini Flash 3.8, Grok 4.3, and Mistral Small all show a 95% CI that includes zero for this comparison. GPT-5.6 Luna is the exception: rotating is 237 ms slower than fixed [158, 342], a small but real effect in the opposite direction from what a naive "extra signing work costs time" story would predict. We do not have an explanation for this one exception; it is reported as observed, not resolved.
Growth with conversation depth is not one universal curve
The shape of that growth differs by model, not just its size:
- Claude Haiku 4.5: a flat, constant offset.
data:stays near 870-1,110 ms on every turn; both presigned arms sit near 1,700-2,000 ms on every turn. The gap does not widen with conversation depth. - Grok 4.3:
data:is nearly flat (1,059 to 1,750 ms). Both presigned arms climb steadily with turn, from about 2,200 ms at turn 0 to 7,300-8,600 ms at turn 9. Everyttft_msabove 6 s in the whole Grok run (58 of 240 calls) is on a presigned arm; zero are ondata:. - Gemini Flash 3.8: both presigned arms climb and grow noisy at later turns (up to 6.3-6.5 s).
data:also moves a lot turn to turn, from a low of about 1.9 s (turn 1) to a turn-8 peak of about 4.8 s, noisy rather than steadily climbing, so part of Gemini's swings are prompt size or per-call variance, not delivery format specifically. - GPT-5.6 Luna:
data:stays roughly flat and noisy (1,066 to 1,630 ms, no clear trend). Both presigned arms climb, rotating more steeply (1,466 ms at turn 1 to 2,364 ms at turn 9) than fixed (1,331 ms to 1,762 ms). The gap betweendata:and presigned widens with turn instead of staying constant, a milder version of Grok's pattern. - Mistral Small: all three arms grow together and by similar amounts over its 7 turns (
data:861 to 1,973 ms; both presigned arms track within roughly 200 ms ofdata:at every turn). Growth here looks dominated by prompt size, with a small, roughly constant delivery-format gap on top.
One curve does not fit all five models. A single "presigned gets worse as the conversation grows" sentence would overstate Claude (flat gap) and Gemini/Mistral's data: arm (which grows almost as much as presigned does).
Reported cached tokens barely move between formats
Within every model, the three arms report nearly the same mean. Claude Haiku 4.5 reports 15,493 to 15,497 (under 0.1%). Mistral Small reports 12,687 to 12,838 (1.2%). Grok 4.3 reports 16,284 to 16,490 (1.3%). GPT-5.6 Luna's fixed arm is the larger gap, 16,894 versus 17,459 on the other two, a 3.2% difference.
This is a descriptive comparison of means, not the paired-bootstrap test used for ttft_ms above. Every one of these gaps is still far smaller than the ttft_ms gap. The cached-token count each provider reports barely moves with delivery format.
Gemini Flash 3.8 reports 6,908 to 6,911 (under 0.1%), but those bars do not answer this question. Its cache was created ahead of time by the harness and holds only the system prompt, not the images, so the token count cannot move when the image format changes.
Limitations
- Reasoning cannot be aligned across providers. Grok 4.3 cannot disable reasoning at all (71 to 573 reasoning tokens on every one of its 240 real calls). Gemini Flash 3.8's floor produced exactly 0 reasoning tokens on all 240 of its calls in this run, no exception. This is why latency is only ever compared within a model, not between models.
- Gemini Flash 3.8 never has a genuine cold start. Its explicit cache is created before that arm's first call in every series, including series 0, so its turn-0 cached-token count (about 6,900) is never zero, unlike the other four models. See the callout above.
- Mistral Small's numbers cover a shallower conversation. Capped at 7 turns instead of 10 by a hard provider-side image-count limit. Its own three arms are equally truncated, so its internal comparison is fair, but its pooled totals are not directly comparable to the other four models' pooled totals.
- Single seed, single day, one region (
eu-west-1), synthetic OCR images. Not real user photos, not concurrent traffic, not a production gateway with retries and multiple regions in play. - One exception went unexplained. GPT-5.6 Luna's rotating arm is measurably slower than its fixed arm; four other models show no such gap. We report the number, not a cause.
- Bucket region versus provider location was not varied. The images sat in an S3 bucket in
eu-west-1. We do not know which region each provider fetches from, so part of the presigned gap may be network distance. No other bucket region was tested. - Image files were small. The 240 PNGs in this run are 61 to 79 KB (median 71 KB), about 94 KB once base64-encoded. Larger images were not tested, and the extra time spent sending
data:bytes would grow with file size. - Upload time on a slow network was not measured.
ttft_msincludes sending the request body, but only from the one machine that ran the harness. At turn 9 adata:request carries about 0.9 MB of base64. On a slow connection that send could take longer than it did here.
What to do with this
- When latency matters, in chat or in agents, send images inline. It was faster on every model we tested, by 104 ms (Mistral Small) to 3.76 s (Grok 4.3).
- On long threads with many images, presigned keeps request bodies small. At turn 9 a
data:request carries about 0.9 MB of base64 for 10 images. Thettft_mspenalty grows with the thread on Grok 4.3 and GPT-5.6 Luna, and stays roughly flat on Claude Haiku 4.5 and Mistral Small. - Do not cache signed URLs to save time. Re-signing every turn made no measurable difference on 4 of 5 models. GPT-5.6 Luna is the exception: rotating was 237 ms slower than fixed.
- Changing format leaves the reported cache almost unchanged. Reported cached tokens moved by under 0.1% on Claude Haiku 4.5, 1.2% on Mistral Small, 1.3% on Grok 4.3, and 3.2% on GPT-5.6 Luna. Gemini Flash 3.8's cache was created ahead of time and holds only the system prompt, so its result does not show anything here.
- Before you decide, measure on your own model and region. The gap varies a lot from one model to the next. Use the same method: time to first token with a timer that starts at the call, a nonce per arm so the caches stay separate, and a paired comparison within one model.
Conclusion
Across five current models, data: URLs reach first token faster than presigned URLs, and the cached-token count each provider reports barely moves between formats. Neither result is universal beyond this setup. Reasoning settings, cache mechanisms, and turn-by-turn growth all differ enough by provider that a number measured on one model should not be assumed to hold on another.
What travels across providers is the method, more than any single number: measure ttft_ms with a clean timer, isolate cache with a per-arm nonce, and compare arms within a model before comparing models against each other.