GEMINI LABJP
ROBOTICS — Gemini Robotics ER 2 is in public preview, covering spatial reasoning, agentic code execution, and multi-robot coordinationSTREAMING — gemini-robotics-er-2-streaming-preview targets real-time streaming over the Live API, with bidirectional audio and video inputSUNSET — Shutdown dates are close: Imagen models on August 17 (gemini-3.1-flash-image is the successor), the Grok 4.1 family on the 20th, and gemini-robotics-er-1.6-preview on the 31stSAMPLING — temperature, top_p, and top_k are now deprecated. If your app exposes them as user settings, that surface needs rethinkingLOGS — The Interactions API now supports developer logs, viewable from the AI Studio dashboardMODELS — Gemini 3.1 Pro remains in preview. 3.6 Flash reached GA on July 21, and 3.5 Flash-Lite suits high-volume subagent workROBOTICS — Gemini Robotics ER 2 is in public preview, covering spatial reasoning, agentic code execution, and multi-robot coordinationSTREAMING — gemini-robotics-er-2-streaming-preview targets real-time streaming over the Live API, with bidirectional audio and video inputSUNSET — Shutdown dates are close: Imagen models on August 17 (gemini-3.1-flash-image is the successor), the Grok 4.1 family on the 20th, and gemini-robotics-er-1.6-preview on the 31stSAMPLING — temperature, top_p, and top_k are now deprecated. If your app exposes them as user settings, that surface needs rethinkingLOGS — The Interactions API now supports developer logs, viewable from the AI Studio dashboardMODELS — Gemini 3.1 Pro remains in preview. 3.6 Flash reached GA on July 21, and 3.5 Flash-Lite suits high-volume subagent work
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 API207Streaming4SSE2UTF-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-06-23
Your Gemini API Average Latency Looks Great — But Some Users Still Get Stuck. Defending p95/p99
Your average TTFT is fast, yet a fraction of users keep hitting frozen responses. That is a tail-latency problem (p95/p99). From measurement to model routing, streaming budgets, cache accounting, and retry design — here are the defenses that actually held up in production, with code.
API / SDK2026-05-26
Coalescing Gemini API Requests with SSE Fan-out: Collapsing 100 Simultaneous Hits into a Single Call
How I rebuilt the post-push-notification thundering herd on a 50M-download wallpaper app into a Cloudflare Durable Objects coalescer with SSE fan-out, cutting Gemini API costs by 92% with 14 days of production telemetry.
API / SDK2026-04-29
Production Streaming UI with Gemini API + TanStack Query — Cancellation, Retries, and Cache Coherence
TanStack Query is optimized for one-shot REST/JSON requests, so streaming responses don't fit naturally. This guide walks through the gotchas of using Gemini API SSE with TanStack Query and the production-grade design patterns that hold up in real apps.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →