Lesson 2 · Telegram Bots on Serverless

Where Bot State Lives (and Why Your Function Forgets)

In lesson 1 you saw that a Vercel function wakes up per request and is free to vanish afterward. This lesson is about the consequence: anything your bot needs to remember — who's paired, where a lesson cursor is — cannot live in a JavaScript variable. Except when it can. Learning to tell those two cases apart is the one skill this lesson teaches.

The amnesia problem, concretely

Imagine the obvious way to remember which chats have run /start:

// ❌ the tempting version — module scope
const pairedChats = new Set<number>()

bot.command('start', (ctx) => { pairedChats.add(ctx.chat.id) })
bot.on('message:text', (ctx) => {
  if (!pairedChats.has(ctx.chat.id)) return ctx.reply('Not authorized.')
  // …
})

On your laptop under long polling this works forever, because one process handles every update. On Vercel it works sometimes — which is worse than never. Two consecutive Telegram updates are two separate HTTPS POSTs, and the platform makes no promise they reach the same function instance. An instance that sat idle gets recycled; a traffic burst spins up fresh ones that start with an empty Set. (Vercel's Fluid Compute does reuse instances across requests, so module scope often survives — that's exactly what makes this bug intermittent rather than immediate.)

update 1 → instance A pairedChats = { 42 }  ✓ paired
update 2 → instance A (warm, remembers)  ✓ still works…
— hours pass, instance A is recycled —
update 3 → instance B pairedChats = { }  ✗ "Not authorized."
The rule: module-scope memory on serverless is a maybe — it survives warm reuse and dies on recycle, with zero warning either way. Anything whose loss would change the bot's behavior must live in a database.

First: what "pairing" even means

A Telegram bot has no login screen. When you create one via BotFather, it gets a public username — anyone on Telegram can search for it and send it a message, right now, no password, no sign-up. And every update your code receives identifies its sender only by a numeric chat.id. So the platform leaves two questions entirely to you: should I talk to this chat at all? and which user of MY app is this chat?

Pairing is the one-time handshake that answers both questions and writes the answer down. It works like pairing Bluetooth headphones: any phone in range could try to connect, so you deliberately approve one, once — and from then on the headphones recognize that phone automatically. For a bot, the handshake produces a stored link chat id → your app's user, and every future message is checked against it. Without pairing, any stranger who stumbled on the bot would be adding words to your backlog.

The link matters in both directions, too. Inbound: "message from chat 42 → that's Alex." Outbound: the daily pipeline knows it wants to push a teach card to the user — it needs the link to find which chat to send it to (findTelegramLinkByUserId).

How w2tAgent remembers pairing

Here, /start is the handshake. It accepts only the one chat id listed in the TELEGRAM_OWNER_CHAT_ID env var — that's the "deliberately approve one phone" step — and then records the link as a Firestore document:

server/domain/telegramLinks.ts
export async function getTelegramLink(chatId: number) {
  const snap = await telegramLinksCollection().doc(String(chatId)).get()
  return snap.exists ? (snap.data() as TelegramLink) : null
}

export async function upsertTelegramLink(chatId: number, userId: string) {
  const link: TelegramLink = { chatId, userId, pairedAt: Date.now() }
  await telegramLinksCollection().doc(String(chatId)).set(link)
  return link
}
server/telegram/bot.ts
async function resolveScopeSilent(ctx: Context): Promise<Scope | null> {
  const chatId = ctx.chat?.id
  if (chatId === undefined) return null
  const link = await getTelegramLink(chatId)   // ← DB read, every update
  if (!link) return null
  const user = await getOrCreateUser(link.userId)
  return { userId: link.userId, language: user.activeLanguage }
}

Notice what statelessness costs: every message and every button tap pays a Firestore round trip just to answer "who is this chat?". That's not an accident or laziness — it's the price of a backend that can be killed and restarted between any two requests. When you build your next bot, budget for this read; don't try to cache your way out of it with module scope, because that recreates the amnesia bug with extra steps.

UI state goes in the database too

Pairing is the obvious case. Less obvious: even where the user is inside a conversation is state. w2tAgent's daily teach card is one Telegram message edited in place, with ◀ / ▶ buttons moving a cursor through three words. Where does the cursor live? Not in the button, and not in memory — in a Firestore doc keyed by the card's message id:

server/domain/teachSessions.ts
// One session doc per teach card, keyed by the card's Telegram
// message id — so every card in the chat history stays interactive
// with its own cursor state, not just the latest one.
function teachSessionDoc(scope: Scope, messageId: number) {
  return sessionsCollection(scope).doc(`teach-${messageId}`)
}

The buttons themselves carry only an action verb — teach:next, teach:back — never the state. Telegram caps a button's callback_data at 64 bytes, so you couldn't stuff a session in there anyway. The pattern that falls out is worth naming, because it generalizes to every bot you'll build:

Thin callbacks, fat database. A button tap sends a verb; the tapped message's id looks up the authoritative state in the DB; the handler mutates that state and re-renders the message. The Telegram message is just a view — the database row is the truth. Bonus: old messages stay interactive forever, because each one can find its own state.

The exception: the one variable that's allowed

Now the twist. The production webhook route does keep a module-scope variable:

server/api/telegram/webhook/[secret].post.ts
let handler: ((req: Request) => Promise<Response>) | null = null

export default defineEventHandler(async (event) => {
  // …secret check…
  if (!handler) {
    handler = webhookCallback(createBot(env.telegramBotToken), 'std/http')
  }
  return handler(toWebRequest(event))
})

Is that a bug? No — and seeing why is the whole lesson. A program holds two very different kinds of things in memory. Some it learned from the world: a human did something — tapped /start, answered a gap — and the variable is the only record that the event ever happened. Others it worked out by itself, from ingredients it still has: env vars, config, its own code.

Think of your phone. Its contacts list and its photo thumbnail cache are both "data on the phone" — but delete the thumbnails and nothing is lost: the phone quietly regenerates them from the original photos, things are just slow for a moment. Delete the contacts and they're gone — no amount of computation can rediscover your friends' phone numbers, because the phone didn't compute them in the first place; it was told them, once, by you.

handler is a thumbnail. It's built from two ingredients — the bot token in env and the createBot function in the code — and every fresh function instance still has both. If the instance dies, the next one rebuilds an identical handler in a few milliseconds; no user could ever tell. That's what rebuildable cache means: a convenience copy of something that can always be remade, kept in memory purely so warm instances skip redoing the work on every request.

A TelegramLink is a contact. It records that a pairing happened — an event in the past. A fresh instance has the env vars and the code, but no formula can recompute "chat 42 belongs to Alex" from them; the only way to know is to have written it down when it happened. That's authoritative state: if the process's memory were the only copy, losing it would change the bot's behavior (the user is suddenly "Not authorized."), so it must live somewhere that outlives the process — the database.

Why "cache" is the right word: a cache is, by definition, a copy kept close for speed while the real thing exists — or can be remade — somewhere else. If a variable is the only copy in the universe of some fact, it is not a cache, whatever the code calls it, and it doesn't belong in module scope.
Question to askRebuildable cacheAuthoritative state
Loss changes behavior?No — only latencyYes — data/access is gone
Can be recomputed from config/env?YesNo — it records events (a pairing, a tap)
Where it may liveModule scope is fineDatabase, always
In this repolet handler, the lazy LLM-chain singletontelegramLinks, sessions/teach-*, sessions/practice

Classify it yourself

1 · Your next bot has a quiz flow: the user is on question 3 of 5. Where does "current question index" live?

2 · You create a database client (like this repo's Firestore connection) once at module scope and reuse it across requests. Legit?

3 · To save Firestore reads, you add const linkCache = new Map<number, TelegramLink>() at module scope in front of getTelegramLink. Safe?

Unclear on anything — how the practice flow validates stale taps, what Fluid Compute changes exactly, or how grammY's built-in session plugin compares to this repo's hand-rolled sessions? Ask your teacher (me) directly, any time.