Data URLs versus rotating presigned HTTPS: latency and cache in multimodal chat APIs

Benchmarks whether sending PNG bytes inline as Base64 data URLs or as Cloudflare R2 presigned HTTPS URLs changes multimodal completion latency. Three LLMs on synthetic OCR.

TL;DR

On every tested model, mean data: completion latency beat fresh presigned HTTPS (+13%, +23%, +39% presigned slowdown, by tier).

Caching tracks the image, not the URL text, for Gemini, OpenAI, and Anthropic here. Rotating presigned URLs did not look like wiping cache just by changing the link on any stack.

From habit to hypothesis

I got used to presigned HTTPS URLs for multimodal payloads. Stored chat JSON stayed small because I did not embed full image bytes in every message. The provider fetches the PNG over HTTPS on their side. I assumed I was moving traffic off my laptops and servers.

A teammate asked a simple follow-up: aside from RAM and log size, does that pattern really make completions faster? This post is what I measured.

My guess was that presigned wins on elapsed time because download at the provider would beat me sending Base64 again on every turn. I had no data, only a gut feeling.

That picture weakens when you remember requests already leave my machines on fast datacenter uplinks. OVH, AWS, and similar hosts are built to accept uploads. Assuming the provider always wins on fetch versus one more POST with the image inside is a rough shortcut for that setup. Slow home upload, mobile, or strict bandwidth caps can still change the tradeoff. So I ran a benchmark.

OCR as the benchmark task

OCR was a good fit. Current multimodal models already do well on clean synthetic screenshots. Outputs are nearly repeatable. I can generate endless labeled pairs in code instead of tuning hand-written creative prompts.

The synthetic pages stick to one recipe: five OCR lines of about ten words each, light backgrounds, fixed layout.

The setup ties each page to fixed ground-truth text baked into the image. Each assistant reply gets compared to that target string with rapidfuzz, yielding a similarity score from 0 (no overlap) through 1 (exact transcript). temperature is 0. Replies stay inside one fenced Markdown code block.

Methodology

This section is the full harness: where it ran, how I kept data: and presigned from sharing the same image cache by accident, how replays and timing work, and what the vendors did on cache accounting.

Host, storage, and why not my flat

The benchmark harness ran on an OVH VPS in a datacenter. I did not drive it from my home machine on purpose: residential upload is often the weak link for large multimodal payloads, and I wanted timings closer to what you get from a production-style host on a datacenter link.

Presigned objects live in Cloudflare R2, so the vendor fetches PNGs from R2 over HTTPS while the API client runs on that same VPS. For data: the harness reads PNGs from local disk and sends Base64. That way neither path is dominated by consumer broadband jitter or caps.

Cache isolation between lanes

To compare data: with presigned without one side eating image-cache hits minted for the other, every call draws from paired PNG skins with the same transcript but different raster bytes:

  • Layout A: white background, dark text.
  • Layout B: off-white background, charcoal text.

Same wording, slightly different RGB, different hashes and bucket keys, subtle enough that a human still reads one page. Each multimodal payload picks variant A or B at random with the same odds whether the replay leg is data: or presigned (data: and presigned share that rule).

Example from series 6, turn 6 (a / b files):

OCR synthetic page, pair variant a (series 6, turn 6). Variant a in the generator: higher-contrast white backdrop and dark type.

OCR synthetic page, pair variant b (series 6, turn 6). Variant b in the generator: slightly tinted backdrop and softened ink so the raster hash diverges.

For presigned, histories use a fresh signed GET URL whenever an image shows up, including rows already stored earlier in the thread.

Replay protocol and wall-clock timing

The harness logs one duration per call as total_time_ms.

Runs iterate over series (independent chats) and turn depth.

Ten independent chats (series) carry unrelated OCR text. Each chat has ten turns (0 through 9) and calls run strictly one after another.

Turn 0: one PNG plus a short OCR instruction. Turns > 0: full history plus one new PNG each time.

total_time_ms starts when the multimodal request begins and stops at the final assistant token. Signing, disk read, and Base64 encoding (when used) finish before that timer starts.

flowchart LR
  subgraph warmup [Before timed calls]
    R2[R2 PNG objects uploaded]
    IDX[Index rows per serie turn method]
  end
  subgraph per_series_per_method [Replay one series]
    T0["Turn 0: user PNG plus OCR brief"]
    T0 --> A0["Assistant OCR"]
    A0 --> T1["Turn 1: full history plus new PNG"]
    T1 --> A1["Assistant OCR"]
    A1 --> Tn["Turns 2 through 9 repeat pattern"]
  end
  warmup --> per_series_per_method

Wire formats, canvas, and models in scope

Method Payload shape
data data:image/png;base64,... inside the multimodal POST
presigned Fresh Cloudflare R2 HTTPS GET URL for each emission and replay

PNG canvas 1920 x 1080.

Reported runs use smaller multimodal models from three vendors: Gemini, OpenAI, and Anthropic, logged as gemini-flash-2.5 (flash-2.5), gpt-5.4-mini, and claude-haiku-4.5 (haiku-4.5). Larger tiers ought to behave in the same rough band, but this single sweep does not prove that for every SKU.

Latency numbers rotate presigned URLs on every replay (including older history rows) so traces never resurrect stale signature strings accidentally. Separate sanity passes reused one fixed HTTPS URL when the same PNG returns; compared to data:, rewriting the signature did not look like a latency win in those extra captures. Nothing in those traces hinted that multimodal backends reuse prior HTTPS fetches because the URL string matched earlier; wall clocks still behaved like vendors pay for decode plus HTTPS pull rather than some URL-hot cache shortcut.

This benchmark pits inlined PNG bytes in data: requests against presigned HTTPS GETs that resolve on Cloudflare R2.

Vendor caching defaults and timer scope

A few knobs matter when you read latency and the cache counters downstream:

  1. OpenAI and Gemini picked up prompt caching via their defaults inside this harness. Claude only caches multimodal payloads you mark explicitly with cache_control; leave that off and Anthropic behaves like caching is absent.
  2. Each vendor publishes cached-input totals under different schemas. Treat cross-row comparisons as directional, not drop-in merges.
  3. Where Claude caching was enabled, ephemeral TTL landed at 5 minutes across these runs.
  4. Some stacks prefetch HTTPS-linked PNG bytes locally before chat timing starts; that prefetch sits outside total_time_ms here.

Question 1: Same accuracy on both lanes?

If rapidfuzz scores disagree, latency numbers are pointless.

Result: mean score 1.0 (100 transcripts each model and method). Choosing data: or presigned did not break OCR accuracy here. Glad that stayed clean, otherwise the latency numbers would be impossible to trust. 😮‍💨

Question 2: Does URL rotation break cache counters?

Side question: every presigned URL string is new each time. Do vendors treat the image as brand new and zero the cached-input totals they expose?

Across replay with fresh signatures, including older turns, claude-haiku-4.5 still showed equal mean cached-input token counts for data and presigned. That lines up with the image driving those counters, not the URL alone, inside each completion payload.

Extra runs kept one fixed presigned HTTPS URL per PNG across history replays and never re-signed old turns. Cached-input totals matched the rotating-URL batches. So rewriting the HTTPS text alone probably does not flush counters when identical bytes repeat, same intuition you carry for ordinary static CDN links.

Combination Cached input tokens (mean)
gemini-flash-2.5 / data 964
gemini-flash-2.5 / presigned 823
gpt-5.4-mini / data 8474
gpt-5.4-mini / presigned 8950
claude-haiku-4.5 / data 7175
claude-haiku-4.5 / presigned 7175

Claude shows the same mean cached-input count for data and presigned. I thought Gemini and OpenAI would too. They do not. I only set explicit cache_control for Claude here. For Gemini and OpenAI I left vendor defaults. Something in those defaults likely treats data: and presigned paths differently. I do not know every knob on their side.

I first ran Claude without setting cache_control. That was a mistake: Anthropic only enables this cache path when you mark it explicitly. Loads of data: requests came back BadRequestError with a PNG download timeout message from Anthropic's side. My client retries the call up to three times, which sometimes unstuck it, but it mostly felt like data: image sends freezing and then succeeding late. Turning cache_control on flattened the failures and trimmed spend. Numbers in the main table still skew faster on data:. During the bad window, presigned tended to survive more cleanly if you optimize for uptime. None of those failure runs feed the published means above.

Question 3: Which path finishes faster?

Each cell pools 100 timed calls (10 series, 10 turns). Totals come from summary_by_model_method.

Model Data mean (ms) Presigned mean (ms) Presigned slowdown
gemini-flash-2.5 2832 3200 +13%
gpt-5.4-mini 1854 2280 +23%
claude-haiku-4.5 2689 3749 +39%

presigned means the vendor pulls the PNG over HTTPS. data: means you already inlined the PNG in the HTTPS POST.

Figure: latency vs turn index

Averages across ten chats per plotted turn. Solid blue is data. Dashed red is presigned. Markers: circle gemini-flash-2.5, square gpt-5.4-mini, triangle claude-haiku-4.5.

Mean completion latency for gemini-flash-2.5, gpt-5.4-mini, and claude-haiku-4.5. Data URLs versus presigned HTTPS. Ten conversational turns.

Single turns swing a lot (queue, longer context). The averaged table still has presigned slower. Gemini can sit below data: on early turns, so I rely on means and the chart together.

What caught me off guard

I expected a tie or a presigned win because my day job habits favor small logs and less RAM, not milliseconds. Mean total_time_ms rose about 13% to 40% when the vendor had to GET from R2 instead of parsing inline Base64 (model dependent), on the same OVH VPS with matching upload and download speed.

Cached-input counts also did not match my neat guess.

Closing take

If you only care about shortest median completion time on this OCR setup, pick data:. Presigned still wins when upload is slow, when you reuse the same images in long chats, or when you need less RAM or smaller stored chats. Those goals sit next to latency; they do not replace it.

I only wired this benchmark through OpenAI, Google Gemini, and Anthropic. I still expect the same directional story on other multimodal hosts, but treat that as guesswork until somebody runs the same split on their APIs.