●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Running a Gemini + LINE Bot in Production — Reply Token Expiry, Duplicate Replies, and Cold-Start Latency
The first walls you hit putting Gemini behind a LINE Bot are the 30-second reply-token expiry, duplicate replies from webhook redelivery, and Cloud Run cold starts. This guide solves them with loading animations, push-message fallback, idempotency, and Firestore-backed history — with working code and measured numbers.
As an indie developer at Dolice, I put Gemini behind a LINE Bot and everything worked perfectly through ngrok on my machine. The morning after I shipped it to Cloud Run, the reports started: "it replies twice" and "sometimes it just goes silent."
Neither was a bug in my code. The LINE Messaging API, a generative model that takes a few seconds, and serverless cold starts simply weren't aligned on the same clock.
This is the record of how I re-aligned them — with working code and the numbers I measured. It still works as a tutorial you can follow from first setup, but most of the space goes to the traps that only show up in production.
If the Gemini API itself is new to you, the Gemini API Quickstart is a gentler starting point.
Get a Minimal Version Running First
Before the traps, let's stand up the smallest version that works. Without this foundation, the later discussion has nothing to stand on.
For a chatbot backend I recommend gemini-3-flash — the balance of speed and price is hard to beat. I switch to gemini-3-pro only for the moments that genuinely need deeper reasoning, which keeps both cost and latency reasonable.
Why a Naive reply_message Breaks in Production
Most samples call Gemini the moment the webhook arrives and return the text via reply_message. Locally, this is fine.
In production, two properties of the LINE reply token start to bite.
First, the reply token expires roughly 30 seconds after it's issued. Second, each reply token can only be used once.
Gemini Flash is fast, but a longer prompt or a busy moment can take several seconds. Stack a Cloud Run cold start on top and you occasionally creep toward that 30-second edge. Reply to an expired token and LINE returns 400 Invalid reply token — the user just gets silence.
Redelivery makes it worse. If your webhook doesn't return 200 quickly enough, LINE resends the same event. Call Gemini and reply on every resend, and the user receives the same answer two or three times. That was the "it replies twice" I hit on day one.
The rest of this guide takes both problems — token expiry and redelivery — head on.
✦
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
✦A concrete way to work around the ~30-second, single-use reply token using a loading animation plus push-message fallback
✦How to stop duplicate replies caused by webhook redelivery by making each event idempotent with its event ID
✦Measured ~4s cold-start penalty on Cloud Run with min-instances 0, and why conversation history belongs in Firestore
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.
The Design: Ack with 200 First, Generate Behind It
The idea is simple. Return 200 the instant the webhook arrives, and move Gemini generation onto a separate thread. That removes the trigger for redelivery.
Then, if generation is likely to fit inside the token's lifetime, use reply_message; if it might not, fall back to push_message. A push message needs no token — just the user ID — so it can be sent later.
# app.pyimport osimport threadingfrom flask import Flask, request, abortfrom google import genaifrom linebot.v3 import WebhookParserfrom linebot.v3.messaging import ( Configuration, ApiClient, MessagingApi, ReplyMessageRequest, PushMessageRequest, TextMessage, ShowLoadingAnimationRequest,)from linebot.v3.webhooks import MessageEvent, TextMessageContentapp = Flask(__name__)GEMINI_API_KEY = os.environ["GEMINI_API_KEY"]LINE_CHANNEL_SECRET = os.environ["LINE_CHANNEL_SECRET"]LINE_CHANNEL_ACCESS_TOKEN = os.environ["LINE_CHANNEL_ACCESS_TOKEN"]gemini_client = genai.Client(api_key=GEMINI_API_KEY)configuration = Configuration(access_token=LINE_CHANNEL_ACCESS_TOKEN)parser = WebhookParser(LINE_CHANNEL_SECRET)def generate_reply(user_id: str, user_message: str) -> str: """Generate a response with Gemini, using conversation history.""" history = load_history(user_id) # from Firestore (below) history.append({"role": "user", "parts": [{"text": user_message}]}) response = gemini_client.models.generate_content( model="gemini-3-flash", contents=history, config={ "system_instruction": ( "You are a friendly assistant running on LINE. " "Answer concisely, around 200 characters." ), "temperature": 0.7, "max_output_tokens": 500, }, ) answer = response.text history.append({"role": "model", "parts": [{"text": answer}]}) save_history(user_id, history) # to Firestore (below) return answerdef process_event(user_id: str, reply_token: str, text: str): """Runs on a separate thread: generate, then reply or push.""" with ApiClient(configuration) as api_client: api = MessagingApi(api_client) # Show a typing indicator so the user knows we're thinking api.show_loading_animation( ShowLoadingAnimationRequest(chat_id=user_id, loading_seconds=20) ) answer = generate_reply(user_id, text) try: api.reply_message( ReplyMessageRequest( reply_token=reply_token, messages=[TextMessage(text=answer)], ) ) except Exception: # Token expired — fall back to push api.push_message( PushMessageRequest( to=user_id, messages=[TextMessage(text=answer)] ) )@app.route("/callback", methods=["POST"])def callback(): signature = request.headers.get("X-Line-Signature", "") body = request.get_data(as_text=True) try: events = parser.parse(body, signature) except Exception: abort(400) for event in events: if isinstance(event, MessageEvent) and isinstance( event.message, TextMessageContent ): if already_handled(event.webhook_event_id): # idempotency (below) continue threading.Thread( target=process_event, args=(event.source.user_id, event.reply_token, event.message.text), daemon=True, ).start() return "OK" # return 200 without waiting for generation@app.route("/health")def health(): return "OK"
Three things are doing the work here. Putting return "OK" before generation removes the redelivery trigger; show_loading_animation tells the user they aren't being ignored; and falling back to push_message when reply_message fails prevents silence.
You can set loading_seconds up to 60, but I aim for around 20 — roughly two to three times the real response time. Too long and users drift away; too short and the indicator vanishes mid-generation.
Stopping Duplicate Replies with Idempotency
Acking with 200 before generation removes almost all redelivery. Still, network hiccups can cause an occasional double delivery. Idempotency closes that last gap.
Every LINE webhook event carries a unique webhookEventId. Use it as the key for "have I handled this already?"
from google.cloud import firestoredb = firestore.Client()def already_handled(event_id: str) -> bool: """Record the event ID; return False only on the first sight.""" ref = db.collection("handled_events").document(event_id) # create() raises if the document already exists — use that as a lock try: ref.create({"ts": firestore.SERVER_TIMESTAMP}) return False # first time except Exception: return True # redelivery / duplicate
create() fails when a document with the same ID already exists. Using that failure as a lock means even multiple instances running at once won't double-process. Set a Firestore TTL to auto-delete handled_events after 24 hours and the collection never grows unbounded.
Moving Conversation History from Memory to Firestore
The conversation_history = {} module variable you see in samples is guaranteed to break in production. With min-instances 0, Cloud Run shuts the instance down when idle, so the next request finds the history empty. Scale to multiple instances and the same user's requests land on different ones, dropping the context.
History belongs in an external store. Data like a LINE Bot's — written about as often as it's read, cleanly partitioned per user — fits Firestore well, in my experience.
Capping history at the last 20 messages balances cost against context. Gemini bills on input tokens, so sending hundreds of turns every time makes each reply needlessly expensive. Twenty messages is usually enough to hold the thread of casual chat or Q&A on LINE.
In the LINE Developers Console (Messaging API tab), set the Webhook URL to your ngrok URL + /callback, turn on "Use webhook," and click "Verify." Add the bot as a friend, send a message, and if it answers, the foundation is done.
Locally the reply token is consumed almost instantly, so expiry is hard to feel — but production, with the cold start below, is a different story.
Answering Images — Multimodal Support
Gemini's strength is that it handles more than text. Let's add a path that analyzes an image the user sends.
import requests, base64from linebot.v3.webhooks import ImageMessageContentdef download_line_image(message_id: str) -> bytes: url = f"https://api-data.line.me/v2/bot/message/{message_id}/content" headers = {"Authorization": f"Bearer {LINE_CHANNEL_ACCESS_TOKEN}"} return requests.get(url, headers=headers).contentdef describe_image(image_bytes: bytes) -> str: image_b64 = base64.b64encode(image_bytes).decode("utf-8") response = gemini_client.models.generate_content( model="gemini-3-flash", contents=[{ "role": "user", "parts": [ {"text": "Describe this image briefly."}, {"inline_data": {"mime_type": "image/jpeg", "data": image_b64}}, ], }], config={"max_output_tokens": 500}, ) return response.text# Add to the callback branch:# elif isinstance(event.message, ImageMessageContent):# handle it on a thread like process_event, choosing reply vs push
Image download goes through LINE's api-data.line.me, and that download itself eats into the 30-second token window. Image responses are exactly where the push_message fallback earns its keep. In my own bots, anything involving an image is built push-first.
Deploying to Cloud Run, and the Cold-Start Numbers
Production runs on serverless Cloud Run. The Dockerfile is straightforward.
The setting that decides how the bot feels is min-instances. In my measurements in asia-northeast1, a min-instances 0 cold start added about 4 seconds before the first response. With generation at 2–3 seconds, the total still fits inside the 30-second token window, but users feel it as "only the very first message is oddly slow."
If that bothers you, set min-instances 1 to keep one instance warm — at the cost of a small always-on charge. Other ways to kill cold starts are collected in mitigating Gemini API cold starts on serverless. My rule of thumb: 0 for a hobby bot, 1 once real users depend on it.
Rough Cost Picture
For small-to-medium scale, the monthly breakdown looks like this:
Cloud Run requests: free up to 2M/month, then about $0.40 per 1M
LINE Messaging API: a pay-as-you-go plan beyond the free message quota
Leaning on push instead of reply increases your LINE message count. Push fallback is effective against expiry, but keep in mind it spends your free message quota.
Rate Limits and Retries
Under load, Gemini may return 429 (rate limited). Wrap the generation call inside the thread with an exponential backoff retry.
Note the catch: the longer you wait between retries, the closer you drift to reply-token expiry. The more retries you allow, the more important push fallback becomes. These aren't independent measures — I treat them as one design. For error-by-error handling including 503, Gemini 3.2 API error troubleshooting goes deeper.
A Pre-Launch Checklist
Before shipping, I verify in this order:
Text responses come back through ngrok
Deliberately insert time.sleep(35) before generation and confirm that, even when the reply token expires, the answer still arrives via push
Simulate a LINE redelivery and confirm the same event ID doesn't deliver twice
Deploy to Cloud Run, leave it idle, then measure the first-response cold-start delay
Hold a conversation of dozens of messages and confirm Firestore history caps at 20
Steps 2 and 3 are behaviors that hide locally and bare their teeth only in production. Breaking things on purpose to check them ahead of time removes almost all of the post-launch "it replies twice" and "it went silent."
The fun of getting something working and the patience of turning it into something people use every day are slightly different crafts. That quieter, unglamorous polishing is what turns a LINE Bot from a prototype into a service. I hope this helps anyone stuck on the same setup.
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.