◉GEMINI LABJP
●OMNI 1.2 — Gemini Omni 1.2 Flash is generally available (11/5) with up to 15-second video and conversational editing; 1.1 Flash is routed to 1.2 on 12/3●COMPUTER — Computer Use is now generally available in the Interactions and GenerateContent APIs (10/30)●LIVE — Live and TTS preview models shut down as early as 11/17 (10 days left). Move to gemini-3.8-live and the 3.8 TTS models●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●2.5 PRO — gemini-2.5-pro and 2.5-flash are routed automatically on 12/1 (24 days left), to 3.8 Flash and 3.5 Flash-Lite●NEW — Stop Re-Reading Every Crash Report: Gre…●OMNI 1.2 — Gemini Omni 1.2 Flash is generally available (11/5) with up to 15-second video and conversational editing; 1.1 Flash is routed to 1.2 on 12/3●COMPUTER — Computer Use is now generally available in the Interactions and GenerateContent APIs (10/30)●LIVE — Live and TTS preview models shut down as early as 11/17 (10 days left). Move to gemini-3.8-live and the 3.8 TTS models●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●2.5 PRO — gemini-2.5-pro and 2.5-flash are routed automatically on 12/1 (24 days left), to 3.8 Flash and 3.5 Flash-Lite●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/API / SDK
◈ API / SDK/2026-08-07Advanced

When Streaming Responses Quietly Lose Non-ASCII Text: Measuring the Byte Boundary and the Event Boundary Separately

I measured why Japanese text disappears from streaming Gemini responses using a local mock SSE server. Two separate layers were broken, one of them silently, and an English-only test suite caught neither.

Gemini API255Streaming5SSEUTF-8Internationalization

✦ Premium Article

I was wiring Gemini's streaming output into a live text view for a wallpaper app, showing description copy as it was generated.

Everything looked fine. Characters appeared one by one, the paragraph filled in, the stream closed cleanly. On my machine it never failed.

What caught my attention was the ending, on a real device. The text was slightly short.

It still read naturally. The grammar was intact. But when I ran the same prompt without streaming and put the two responses side by side, the final sentence was missing entirely.

The error log was empty. The status was 200. Nothing had thrown.

Nothing appeared broken, and yet something had not arrived. That combination bothered me enough to stop guessing and start measuring, so I built a mock server locally.

Two separate layers turned out to be broken. One of them failed in exactly the opposite way from what I had assumed.

The measurement setup

Depending on a live service makes numbers move around. Instead I served a byte-for-byte imitation of Gemini's streamGenerateContent?alt=sse response shape from localhost.

  • Python 3.10.12 and Node.js v22.22.3
  • Payload: 8 SSE events, 1,406 bytes total. Of that, the Japanese body text is 85 characters — three bytes each, so 255 bytes, or 18.1% of the stream
  • A structurally identical English payload (1,315 bytes) for comparison

The important property is that the server controls exactly how many bytes it writes to the socket at a time. Real networks fragment responses through TCP segmentation and proxy buffering, and you can neither observe nor control that. So force it on the test side instead.

# server.py — a mock SSE server that writes a fixed number of bytes at a time
import socket, sys, time, json
 
JA_PARTS = [
    "画像分類パイプラインの", "移行を進めています。", "停止予定のモデルを",
    "洗い出したところ、", "参照箇所は思ったより", "広範囲に散っていました。",
    "特に設定ファイル側の", "既定値が見落とされがちです。",
]
 
def build(parts, model="gemini-3.6-flash"):
    """Assemble a body with the same shape as a Gemini SSE response."""
    out = []
    for i, p in enumerate(parts):
        obj = {
            "candidates": [{
                "content": {"parts": [{"text": p}], "role": "model"},
                "index": 0,
            }],
            "modelVersion": model,
        }
        if i == len(parts) - 1:
            obj["candidates"][0]["finishReason"] = "STOP"
            obj["usageMetadata"] = {
                "promptTokenCount": 48,
                "candidatesTokenCount": 96,
                "totalTokenCount": 144,
            }
        # ensure_ascii=False matters: emit real UTF-8, not \u escapes
        out.append("data: " + json.dumps(obj, ensure_ascii=False) + "\n\n")
    return "".join(out)
 
def serve(port, chunk):
    body = build(JA_PARTS).encode("utf-8")
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("127.0.0.1", port))
    s.listen(8)
    conn, _ = s.accept()
    with conn:
        conn.recv(65536)  # discard the request line
        conn.sendall((
            "HTTP/1.1 200 OK\r\n"
            "Content-Type: text/event-stream; charset=utf-8\r\n"
            "Cache-Control: no-cache\r\n"
            f"Content-Length: {len(body)}\r\n"
            "Connection: close\r\n\r\n"
        ).encode())
        # the point of the exercise: deliberately fragmented writes
        for i in range(0, len(body), chunk):
            conn.sendall(body[i:i + chunk])
            time.sleep(0.001)
    s.close()
 
if __name__ == "__main__":
    serve(int(sys.argv[1]), int(sys.argv[2]))

That time.sleep(0.001) is not decoration. Without it the kernel coalesces the writes in the send buffer, the receiver sees whatever size it likes, and the chunk parameter stops meaning anything. I left it out on the first pass, got identical results at every chunk size, and spent a while confused before working out why.

The byte boundary breaks only non-ASCII text

I started with the most common shape of mistake: decode each received chunk on its own with decode("utf-8", errors="replace").

Varying the server's write size, I counted how many U+FFFD replacement characters appeared in the reassembled body. Separately, I walked the payload bytes analytically to count how many boundaries landed in the middle of a multi-byte character.

Write sizeBoundariesCharacters splitU+FFFD emittedShare of the 85-char body corrupted
8 bytes175215424.7%
12 bytes117204023.5%
16 bytes87102611.8%
24 bytes5891810.6%
32 bytes435135.9%
48 bytes29484.7%
64 bytes21252.4%
128 bytes10121.2%
256 bytes5000%

Each split character produced either two or three replacement characters. A three-byte sequence cut as 2+1 yields two; cut as 1+2 it yields three. The measured counts fall exactly into those two buckets, which is a useful sanity check that the harness is measuring what it claims to.

Running the identical sweep against the English payload is what convinced me this was worth writing up.

Across all 40 conditions — five implementations times eight chunk sizes — zero corruption.

ASCII is single-byte, so no boundary can ever land inside a character. Japanese is three bytes, giving every character two positions where a cut is fatal. Emoji are four bytes, giving three.

# how many cut positions are fatal, per character width
s = "完了しました🎉"
b = s.encode()
for i in range(1, len(b)):
    joined = b[:i].decode("utf-8", "replace") + b[i:].decode("utf-8", "replace")
    n = joined.count("�")
    if n:
        print(f"split@{i:2} -> U+FFFD={n}  {joined!r}")
# split@ 1 -> U+FFFD=3  '���了しました🎉'
# split@19 -> U+FFFD=4  '完了しました����'

A four-byte emoji can produce four replacement characters from a single cut. If your product surfaces emoji in generated copy — and most consumer-facing ones do — this is not a localization-only concern. It is simply a non-ASCII concern, and emoji are more fragile than kanji.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Corruption rates measured per chunk size (24.7% of body text at 8 bytes, 0% at 256 bytes) plus the complete mock SSE server you can run yourself
✦Why the same mistake raises an exception in Python but silently substitutes U+FFFD in Node.js, and the correct receive loop for both runtimes
✦The second boundary that survives a UTF-8 fix, where whole events vanish behind a 200 response, and the adversarial server to put in CI
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◈ API / SDK2026-08-23
Streaming Gemini TTS: concatenate the PCM, write the WAV header once
Streamed Gemini TTS does not arrive as an audio file. It arrives as raw PCM fragments. Here is what happens when you wrap each fragment in its own WAV header, measured on my machine, plus the receiving code that avoids it.
◈ API / SDK2026-10-30
An 'API key expired' Error Doesn't Always Mean Expired. Check Which Key Is Actually Sent Before You Renew It
A 400 with API_KEY_INVALID from the Gemini API cannot tell you whether the key expired or something else is wrong. A small diagnostic script checks env-var precedence and stray quotes first, run here against dummy keys.
◈ API / SDK2026-10-30
My ADK Agent Never Ran and Said 'API key expired' — Reading the Reason Code Gave Me a Triage Order
When ADK returns 400 INVALID_ARGUMENT 'API key expired. Please renew the API key.' on every run, check this order before assuming the key expired: read the reason code, compare shell and .env keys by their last four characters, then test the key alone over REST.