I spent a whole evening staring at a gray spinner because an AI feature in a prototype decided it needed 'a moment.' No queue position, no retry path, no idea if the request had died three minutes ago. That is not a technical failure. That is a design failure dressed up as a loading state.
Then I remembered how often we hand a wait state to a component library and call it done. When AI calls are slow, rate-limited, or retried silently, a spinner hides the one thing the user actually needs: a decision.
That is the part I wanted to fix. And I wanted to fix it without touching production, so I used a free model and a free server to build a tiny status lab instead of guessing at UX copy.
A wait state is a decision, not a progress bar
Most of our AI flows have at least four states:
- Fast enough to feel instant.
- Slow enough that a user starts doubting the request.
- Rate-limited or queued.
- Failed after retries.
Each state asks a different question. 'Please wait' is an answer to exactly one of them.
If the system knows it is throttled, it should say so and offer a smaller retry. If it is retrying, it should say how many tries were made and when the user can stop. If it failed, the screen should preserve the user's input, not just show an error code.
Build the lab before you need the copy
I wanted to see which copy works, so I built a small decision table and then asked a model to draft non-final language for each state. This is not production copy. It is raw material for a design review.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The operator says MonkeyCode currently offers free model access and a free server option, with a 30M-token allowance. The project is described as open source; if that matters to your change-review process, check the license and governance before depending on it.
The free server was useful because I could keep the same prompt, same model version, and same JSON output across dozens of state variations. The 30M tokens were more than enough for a few hundred generated status lines.
Here is the prompt shape I used. It is pseudocode, not a MonkeyCode endpoint.
STATES = [
{'id': 'slow_2s', 'input': 'request accepted, expected between 1 and 3 seconds'},
{'id': 'slow_10s', 'input': 'queue depth visible, expected 8 to 15 seconds'},
{'id': 'rate_limited', 'input': 'retry after 30 seconds, previous attempt preserved'},
{'id': 'failed_retries', 'input': 'two retries failed, user input not lost'},
]
SCHEMA = '''
Return JSON only with these keys:
- title: one short line, no definite promise.
- body: one sentence, says what the system is doing.
- action: one concrete action the user can take. If the system should not ask the user to take action, return null.
- expected: the expected duration or interval. If unknown, say unknown.
- aria: a text alternative that does not rely on color or motion.
- tone: calm or urgent.
'''
The important rule I set for the generated copy: never hide uncertainty in a smooth sentence. If the model does not know the expected time, it should write 'the system has not reported an estimate,' not 'one moment please.'
Then I recorded every result in a JSONL file, including the generated draft, the state id, and my pass/fail decision. The log was the point, not the pretty table.
The moment a human should override
The model produced calm, clear copy for the slow states. It also produced some very corporate nonsense for rate-limited states, like 'we are optimizing your request for a better experience.' That is exactly the kind of line a design review should kill.
One of the rate-limited drafts read: 'Your request is important to us and is being handled with care.' I marked it fail because the system could not prove care, and the sentence gave no action. The better draft I kept was: 'We could not start your request yet. It will retry automatically in about 30 seconds. You can leave this page; your text is saved.'
So the final flow is not 'generate and ship.' It is:
state observed
|
v
does the system know the expected duration?
|
+--- yes -> show expected duration + one action
|
+--- no -> show what is known + a stop condition
|
v
was the input preserved?
|
+--- yes -> show where it is and how to return
|
+--- no -> stop; do not show a spinner, show recovery
A human reviews any line where the tone moves from calm to urgent without a clear reason, or where the generated text uses a promise the system cannot keep.
Accessibility before screenshot review
A wait state is not just visual. If you rely on a pulsing dot alone, screen-reader users get silence. I required every generated state to include an aria line, and I rejected any draft that only used color or motion to communicate urgency.
That is a small thing, but it changes the work. You stop shipping a component and start shipping a small contract: the system tells you what it knows, what it does not know, and what you can do next.
Limitations
This is a copy and state-machine drafting exercise, not a production latency test. It does not tell you how long a real request will take, whether the rate limit is honestly reported, or how a screen reader will announce a live region in every browser. Generated copy needs design, legal, and localization review before it goes anywhere near users.
Do not start here if you need strict SLAs, medical or financial flows, or real-time systems where a delayed response must be treated as a medical or legal event. Use a real incident-design process there.
If you have free access to a model and a server, try this before you paste another spinner into a prototype. The next user stuck on your screen will care less about how clean the animation is and more about whether they can stop waiting and get their input back.
Top comments (0)