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.
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.)
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).
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:
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.
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:
Now the twist. The production webhook route does keep a module-scope variable:
server/api/telegram/webhook/[secret].post.tslet 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.
| Question to ask | Rebuildable cache | Authoritative state |
|---|---|---|
| Loss changes behavior? | No — only latency | Yes — data/access is gone |
| Can be recomputed from config/env? | Yes | No — it records events (a pairing, a tap) |
| Where it may live | Module scope is fine | Database, always |
| In this repo | let handler, the lazy LLM-chain singleton | telegramLinks, sessions/teach-*, sessions/practice |
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.