A message would arrive and the bot would answer roughly ten seconds later. On WhatsApp that is long enough that people send the message again, which makes it worse.
There was no single slow thing. That's what took the time to find. Every individual step was fine; there were just a lot of them, all waiting their turn.
Twelve reads for one config
The biggest one. Handling a message meant loading the business's configuration, and that had grown over time into twelve or more separate reads, each one a round trip, each one waiting for the last.
The thing about that config is that it is read on literally every inbound message and it changes approximately never. That is the ideal cache shape. One lookup, five-minute TTL, explicit invalidation when the config is edited so a merchant never waits out a TTL to see their own change.
Twelve round trips became one.
Serial by habit, not by need
Further down, six more reads in a row. Written as you'd naturally write them:
const retailer = await getRetailer(phone);
const session = await getSession(phone);
const store = await getStore(storeId);
// ...three more
None of them needed the one before it. await makes sequential code look so
natural that you stop noticing you've serialised six independent things.
const [retailer, session, store, ...rest] = await Promise.all([
getRetailer(phone),
getSession(phone),
getStore(storeId),
// ...
]);
A couple genuinely did depend on earlier results, so those stayed in a second round. Two waves instead of six steps.
Waiting to say "typing"
My favourite one, because it's so obviously wrong once you see it. The handler sent a typing indicator and awaited it — 100 to 200ms of blocking, on every message, to tell someone we were about to do work we were now delaying.
It's a hint. Nothing downstream depends on it. Fire it and move on:
sendTypingIndicator(phone).catch(() => {}); // deliberately not awaited
The .catch is not optional. An unhandled rejection from a floating promise will
take the process down in modern Node, and a cosmetic nicety should never be able to do that.
An LLM call in the queue
Language detection ran before the main work: a ~500ms model call, sitting on the critical path, blocking everything behind it.
It didn't need to be there. Start it alongside the other work and collect it at the point the answer is actually needed. Same call, same cost, off the path.
There was also a mode where the bot doesn't reply at all — a human has taken the conversation over. It was still doing the full pipeline first and discarding the result. Checking that early skips everything.
Roughly ten seconds to one or two
No rewrite, no new infrastructure. A cache, a Promise.all, one unawaited call,
and moving one thing off the critical path.
What I'd take from it: when something is uniformly slow rather than spiky, stop looking for
the slow query. You're usually looking at accumulated serialisation — a pile of small
waits that each looked reasonable when they were added. Ask of every await
whether the next line actually needs its result. Often several in a row don't.