Skip to content
KONIGI

AI Assistants / Limits and failure / Usage meter

3 of 3

Usage meter

The viewer is spending a finite resource they cannot see until it runs out.

Updated September 12, 2026

Problem

Someone is four hours into a working session and the product stops. There was no warning, no sense of how close they were, and no way to have spent the budget differently, because they couldn’t see it.

Solution

Show the limit before it binds. Quotas in these products are unusual in being both invisible and consumable at wildly varying rates, since one heavy request can cost what forty light ones cost. That combination makes the resource impossible to budget by feel, which is the argument for a meter that most other software doesn’t need.

Three things have to be visible, and only the first is commonly shipped:

  1. How much is left, in a unit the viewer can convert into work. A percentage is comprehensible. Tokens aren’t.
  2. When it resets. A limit with an unknown reset is indistinguishable from a wall, and the difference between waiting twenty minutes and waiting until next month determines whether someone goes and finds another tool.
  3. What the expensive things are. A viewer who learns that the deep research mode costs twenty times a normal question can make a choice. Without that, the meter reports a mystery.

Point three is where HAX guideline 16 applies directly: convey the consequences before the action. Marking cost at the point of choice, on the slow mode and the expensive model, is more useful than any amount of accuracy in the meter itself, because it reaches the viewer while they can still decide.

Thresholds beat continuous display. A meter shown at all times is ignored and makes an unbounded product feel like a metered one. Staying quiet until roughly three-quarters consumed, then appearing, gives the viewer room to change behaviour and keeps the surface clean for the majority of sessions that never approach the ceiling.

The moment of exhaustion needs to be treated as a designed state rather than an error. PAIR’s graceful failure argument fits exactly: say what happened, say when it ends, and offer a path. A cheaper model that still works, a queued request, or a clear upgrade route all leave someone able to continue. A bare notice that the limit is reached ends the session.

Attribution matters where a quota is shared. On a team plan, a limit consumed by a colleague is invisible and infuriating, and saying whose usage filled the bucket is the difference between a product problem and a people problem.

Use when

Usage is metered, limits are reachable in normal work, and costs vary enough between requests that people can’t estimate them.

Don’t use when

The limit is high enough that ordinary use never approaches it. Displaying a meter that reads full forever trains people to ignore the one indicator that would matter, and makes a generous product feel stingy.

Trade-offs

A visible meter changes behaviour, and not always for the better: people ration, use worse models for hard questions, and stop exploring. Hiding it produces the wall. Per-request cost marking is the most useful disclosure available and turns every interaction into a purchase decision. The unit is a genuine dilemma. Tokens are the honest measure, and almost nobody can turn a token count into an amount of work they recognise.

Checklist

  • Is the remaining budget visible before it becomes urgent?
  • Is the reset time stated precisely?
  • Are expensive actions marked at the point of choice?
  • Is the unit something a viewer can turn into an expectation of work?
  • Does a stopped or regenerated response still count, and is that said?
  • On a shared plan, can the viewer see whose usage applied?
  • Is exhaustion a designed state with a path forward?
  • Is there a cheaper option that still works at the limit?
  • Does reasoning or tool use count separately from the visible answer?
  • Does the meter stay quiet during the sessions that never approach the ceiling?

Compare

Claude publishes its rate limits and reset behaviour in documentation, so the ceiling is a checkable property rather than something discovered in use. ChatGPT meters by model tier and degrades to a lesser model at the limit. The session stays alive, and the drop in quality is the signal that a limit was hit. Perplexity counts the expensive mode separately from ordinary questions, which makes the costly action legible as its own budget rather than a silent drain on a shared one. GitHub Copilot meters premium requests against a monthly allowance and shows the consumption per request type. Nothing else here is itemised so finely.

Model picker and mode switch are where the expensive choices are made and where cost marking belongs. Regenerate is the most common way a budget disappears unnoticed. Refusal is the other kind of no, where the limit is policy rather than quota. Context meter is the parallel limit measured in memory rather than money.

Usage meter anatomy A quota bar appearing at three-quarters consumed rather than at the wall, with a reset time, per-action cost marked at the point of choice, a named colleague's share on a team plan, and a cheaper model still available at the limit. Show the limit while there is still room to act you, 300 a colleague, 152 meter appears resets Tue 09:00 answer now 1 credit research 40 credits at the limit, the fast model still answers a stopped response still consumed what it wrote reasoning and tool calls count, and are invisible 1 2 3 4 1 A RESET, NOT A WALL Twenty minutes and next month are different products to the person waiting. 2 COST AT THE CHOICE More useful than accuracy in the meter, because it arrives while they can decide. 3 SOMETHING STILL WORKS Exhaustion is a designed state. A bare notice that the limit is reached ends it. 4 THE INVISIBLE SPEND Retries, reasoning and tool calls drain the same bucket without being seen. A meter shown always is ignored, and makes an unbounded product feel like a metered one.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A quota bar that appears at three-quarters rather than at the wall, with the reset time, the per-action cost at the point of choice, and a colleague's share on a team plan. Reasoning and tool calls count, and are invisible.

shadcn
npx shadcn@latest add button
Tokens
--card--card-foreground--muted--muted-foreground--border--chart-1--scale-seq-2--scale-seq-3
you, 300a colleague, 152resets Tue 09:00

at the limit, the fast model still answers

a stopped response still consumed what it wrote: 6 credits

UsageMeter.tsxRenders nothing under three-quarters. Reset, per-action cost and the fallback are required props; a stopped response is charged and says so.

import { Button } from "@/components/ui/button";

/** Who consumed what. On a team plan the colleague's share is the difference
 *  between a product problem and a people problem. */
export type Share = { who: string; used: number };

/** A thing the viewer can choose, with what it costs. The cost is required:
 *  marking it here, while they can still decide, is worth more than any
 *  accuracy in the bar. */
export type Action = { id: string; label: string; cost: number };

/** Shown from three-quarters consumed. Always-on is ignored, and makes an
 *  unbounded product feel metered. */
const APPEARS_AT = 0.75;

const SHARE_COLOR = ["bg-scale-seq-3", "bg-scale-seq-2", "bg-scale-seq-1"];

const resetLabel = (at: Date, now: Date) => {
  const days = (at.getTime() - now.getTime()) / 86_400_000;
  return days < 7
    ? at.toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" })
    : at.toLocaleDateString("en-GB", { day: "numeric", month: "short", timeZone: "UTC" });
};

export function UsageMeter({ limit, unit = "credit", shares, resetsAt, now = new Date(), actions, onChoose, fallback, stoppedCost }: {
  limit: number;
  /** Something the viewer can turn into work. "credit", not "token". */
  unit?: string;
  /** The viewer first. */
  shares: Share[];
  /** A limit with no reset is a wall. */
  resetsAt: Date;
  /** The clock to count against. Pass one to render on a server. */
  now?: Date;
  actions: Action[];
  onChoose: (id: string) => void;
  /** What still works once the budget is gone. Exhaustion is a designed state. */
  fallback: string;
  /** What the last stopped response cost, if one was stopped. It counted. */
  stoppedCost?: number;
}) {
  const used = shares.reduce((n, s) => n + s.used, 0);
  if (used / limit < APPEARS_AT) return null;
  const left = Math.max(0, limit - used);
  const plural = (n: number) => `${n} ${unit}${n === 1 ? "" : "s"}`;

  return (
    <div className="text-xs text-muted-foreground">
      <div className="flex h-4 w-full overflow-hidden rounded-sm bg-muted" role="progressbar" aria-valuenow={used} aria-valuemax={limit}
        aria-label={`${used} of ${limit} ${unit}s used`}>
        {shares.map((s, i) => (
          <span key={s.who} className={SHARE_COLOR[i % SHARE_COLOR.length]} style={{ width: `${(s.used / limit) * 100}%` }} />
        ))}
      </div>
      <div className="mt-2 flex flex-wrap gap-x-4 tabular-nums">
        {shares.map((s) => <span key={s.who}>{s.who}, {s.used}</span>)}
        <span className="ml-auto">resets {resetLabel(resetsAt, now)}</span>
      </div>

      <div className="mt-4 flex flex-wrap gap-3 border-t pt-4">
        {actions.map((a) => {
          const dear = a.cost > left;
          return (
            <Button key={a.id} variant="outline" onClick={() => onChoose(a.id)} disabled={dear}
              className={`h-auto flex-col items-start gap-1 px-3 py-2 ${a.cost > 1 ? "border-chart-1 bg-chart-1/10 text-chart-1 hover:bg-chart-1/20 hover:text-chart-1" : "bg-muted"}`}>
              <span>{a.label}</span>
              <span className="text-[11px] font-normal tabular-nums">{plural(a.cost)}{dear && ", more than is left"}</span>
            </Button>
          );
        })}
        <p className="flex-1 basis-40 self-stretch rounded-md border bg-muted px-3 py-2 text-card-foreground">
          at the limit, {fallback} still answers
        </p>
      </div>

      {stoppedCost !== undefined && (
        <p className="mt-4">a stopped response still consumed what it wrote: {plural(stoppedCost)}</p>
      )}
    </div>
  );
}

demo.tsxHow it is called: a 600-credit team plan with 452 spent. Choosing an action spends its cost.

import { useState } from "react";
import { UsageMeter, type Share } from "./UsageMeter";

/**
 * A team plan of 600 credits with 452 gone, so the meter has appeared. The
 * viewer used 300, a colleague 152, and a response stopped a moment ago still
 * cost 6. Choosing an action spends its cost against the viewer's share.
 */
const NOW = new Date("2026-09-15T10:00:00Z");

export default function Demo() {
  const [shares, setShares] = useState<Share[]>([
    { who: "you", used: 300 },
    { who: "a colleague", used: 152 },
  ]);
  const spend = (cost: number) =>
    setShares(([you, ...rest]) => [{ ...you, used: you.used + cost }, ...rest]);

  return (
    <div className="rounded-lg border bg-card p-4">
      <UsageMeter
        limit={600}
        shares={shares}
        resetsAt={new Date("2026-09-22T09:00:00Z")}
        now={NOW}
        actions={[
          { id: "answer", label: "answer now", cost: 1 },
          { id: "research", label: "research", cost: 40 },
        ]}
        onChoose={(id) => spend(id === "research" ? 40 : 1)}
        fallback="the fast model"
        stoppedCost={6}
      />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.