Code › tail-villain
Thirty Seconds
I pressed the button and waited thirty seconds. Technically, nothing was wrong. That was the problem.
The same week, I finished a piece of work that felt meaningful at the time.
Roadmaps needed ownership. A roadmap takes a job description and a resume, then generates a personalized interview prep plan — but until then, every roadmap in the database belonged to no one in particular. I added an ownerId, put AuthGuard on every route, and wired ownership checks into every query. It worked as expected. It was done quickly.
The real story that day was something else.
Roadmap analysis had been running on a keyword-matching approach that Codex had implemented on its own, not how I’d intended. It classified job descriptions by checking them against a fixed list of words. The idea that a handful of keywords could meaningfully understand a JD didn’t hold up. I decided to replace it with a direct Gemini API call — let the model actually read the document.
I connected it and ran a test. Pressed the create button and waited.
Ten seconds. Twenty. Thirty.
The response came back.
The output was good. Gemini read the JD and returned structured analysis — role type, seniority, strengths, gaps, focus areas. Nothing the keyword list could have produced.
But the thing I noticed first wasn’t the quality. It was that I’d waited thirty seconds.
A thirty-second form submit looks broken. It doesn’t matter how good the result is. Without any signal that something is happening, people assume something went wrong and leave. The output quality comes second.
Technically, nothing was wrong. That was the problem.
I changed the approach. Press the button, save the roadmap immediately, run the AI analysis in the background. The user moves on to the next screen right away. When the analysis finishes, the results fill in automatically.
It’s how a food delivery app works. You place the order and the confirmation screen appears instantly. The kitchen is working, the app knows it, and you don’t have to stand there waiting for it to tell you so.
The implementation used polling. The frontend asks the server “is the analysis done yet?” on a short interval, and when it is, the results appear. The proper approach for async work like this is a message queue — something like SQS — where jobs get queued, processed in order, and retried automatically on failure. But standing up queue infrastructure at this stage would have been more complexity than the problem warranted. Polling was enough.
For the user, the feeling changes from waiting to being waited on.
There were two errors that day.
The first was the backend crashing. I’d added new columns to the schema but skipped the local database migration. Prisma tried to reference columns that didn’t exist yet. I read the logs, ran the migration, restarted.
The second was a Gemini timeout. On the free tier, requests were sometimes cut off around twenty seconds. I raised the timeout limit to forty-five seconds and updated the error handling so failures surfaced the actual message instead of a generic “analysis failed” toast. You need to know what went wrong to fix it.
AI-backed features need a different interaction model than a normal form submit.
When the keyword list was doing the analysis, it was fast. Gemini replaced it and brought latency with it. The moment that happened, the interaction model had to change too.
When you build a feature with an LLM running behind it, how long users have to wait isn’t an afterthought. It’s part of the design.