DEV Community

Cover image for I Built a Drag Race for LLMs, and the Loser Is Usually the Better Model
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

I Built a Drag Race for LLMs, and the Loser Is Usually the Better Model

A technical deep-dive into The Reasoning Tax Race — streaming SSE, GPU particle exhaust, and making an invisible cost visible


There's a chart that every team shipping LLM features has looked at. It has two bars: one for the old model, one for the new one. The new bar is taller. The caption says something like "improved reasoning."

What the chart doesn't show you is the invoice.

Newer reasoning models think before they answer. They emit hundreds of tokens you never see, you never read, and you absolutely do pay for. On a benchmark table that's an abstraction. In production it's your latency budget and your margin.

I wanted to make that cost visible as a physical object — something you could watch and feel rather than read in a table. So I built a drag race.


The premise

Two models. One prompt. Identical bytes to both, fired at the same instant. Each gets a
lane and a rocket.

  • Rocket position = tokens emitted
  • Exhaust plume = every token, as a particle
  • First across the finish line = winner

Then the twist: the slower model is usually the better one, and its exhaust trail is
visibly, dramatically longer. That trail is the reasoning tax.

Two rockets racing, with the loser trailing a much longer plume

The rule I set for myself was simple and non-negotiable: everything must be genuinely
measured live.
No fake timers, no pre-baked token counts, no simulated latency. If a
model stalls, its rocket stops and its plume stops growing. If I couldn't measure it, it
didn't go on screen.

That constraint turned out to be the entire engineering problem, and it produced the
most interesting decisions in the project.


Architecture

┌──────────────────────────────────────────────────────────────────────────┐
│  BROWSER  ·  :5173                                                       │
│                                                                          │
│   React 18 + TypeScript                                                  │
│   ┌────────────────┐  ┌─────────────────┐  ┌──────────────────────────┐  │
│   │  Control deck  │  │   Lane HUDs     │  │   Reveal panel           │  │
│   │  prompt+presets│  │ tokens/reasoning│  │  answers · latency ·     │  │
│   │  Start / Reset │  │ stopwatch · TTFT│  │  reasoning tok · sha256  │  │
│   └────────────────┘  └─────────────────┘  └──────────────────────────┘  │
│           │                    ▲                        ▲                │
│           │ useRace()          │ live telemetry         │ outcome        │
│           ▼                    │                        │                │
│   ┌──────────────────────────────────────────┐          │                │
│   │  arrivalsRef  ──drainArrivals()──┐       │          │                │
│   └──────────────────────────────────┼───────┘          │                │
│                                      ▼                  │                │
│   ┌──────────────────────────────────────────────────┐  │                │
│   │  three.js RaceScene   (own rAF loop)             │  │                │
│   │   • 2 rockets      • GPU particle exhaust        │  │                │
│   │   • starfield      • finish gate + camera rig    │  │                │
│   └──────────────────────────────────────────────────┘  │                │
└──────────────────────────────┬───────────────────────────────────────────┘
                               │
              POST /api/race   │   SSE: start · first-token · tick ·
              (proxied by Vite)│        reasoning · retry · done · finish
                               ▼
┌──────────────────────────────────────────────────────────────────────────┐
│  NODE SERVER  ·  :3001   (Express + TypeScript)                          │
│                                                                          │
│   POST /api/race ──► runRace(prompt, config, emit)                       │
│                        │                                                 │
│                        ├──► runModel('A', modelA, …) ─┐  Promise.all     │
│                        └──► runModel('B', modelB, …) ─┘  (concurrent)    │
│                                    │                                     │
│                        streamOnce() parses provider SSE,                 │
│                        counts tokens, never stores reasoning text        │
└────────────────────────────────────┬─────────────────────────────────────┘
                                     │  fetch(…, { stream: true })
                                     ▼
              ┌────────────────────────────────────────────┐
              │  OpenAI-compatible provider                │
              │  POST {baseUrl}/chat/completions           │
              └────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The stack is deliberately boring: Vite + React + TypeScript + three.js on the
front, Node + Express + TypeScript on the back. No database, no auth, no state
library, no model SDK. Model calls are plain fetch against the OpenAI-compatible
/chat/completions endpoint — which means pointing the whole thing at Ollama, vLLM, or
OpenRouter takes seconds and zero code changes.


Making the visual be the data

This is the decision the project lives or dies on.

It would have been easy to draw two rockets moving at speeds proportional to
tokens/second, or to give each lane its own scale so both look dramatic. Both would
have been lies. The gap between the rockets has to mean something.

So both lanes share one unitsPerToken:

// web/src/three/RaceScene.ts

private tokenToWorldX(tokens: number): number {
  return START_X + tokens * this.unitsPerToken;
}
Enter fullscreen mode Exit fullscreen mode

One shared scale means the horizontal distance between the rockets is always exactly
proportional to the difference in tokens emitted. It's not decoration. It's the data,
rendered.

But a fixed scale has a problem: you don't know in advance how many tokens a model will
emit. Too small and the rockets fly off screen; too large and they never leave the
start line. So the scale eases while racing, keeping the leader framed.

Then comes the moment that makes the whole thing work:

/** Plant the finish line at the first finisher's token count and freeze scale. */
plantFinish(tokenCount: number) {
  const distance = Math.max(40, tokenCount);
  this.targetUnitsPerToken = FINISH_X / distance;
  this.unitsPerToken = this.targetUnitsPerToken;
  this.frozen = true;
  this.finishGate.visible = true;
  this.finishGate.position.x = FINISH_X;
  this.finishGate.scale.set(1, 0.01, 1);
}
Enter fullscreen mode Exit fullscreen mode

The instant the first model finishes, two things happen: the scale freezes, and the
finish line is planted at that model's token count.

The slower model is still emitting. It keeps driving forward — straight past the finish
line, trailing a plume that keeps growing. The overshoot is drawn to scale. The
overshoot is the tax.

This is why the visual argument works where a bar chart doesn't. You don't read that
model B cost more. You watch it lose the race and then keep going anyway.


The exhaust is not a particle effect

Here's where "genuinely measured" got hard.

My first implementation spawned exhaust on a timer, with some randomness for texture. It
looked great. It was also completely fake — a model that stalled mid-generation would
keep puffing smoke like nothing was wrong. That's a lie told by a visual, which is worse
than a lie told by a number, because you feel it instead of reading it.

The fix was to make the render loop consume real token arrivals. The hook buffers them:

// web/src/hooks/useRace.ts

/**
 * Real token arrivals buffered between frames. The scene drains this each
 * frame and spawns exactly that many exhaust particles — so if the model
 * stalls, the plume stops growing.
 */
const arrivalsRef = useRef<Arrival[]>([]);

const drainArrivals = useCallback(() => {
  if (arrivalsRef.current.length === 0) return [];
  const drained = arrivalsRef.current;
  arrivalsRef.current = [];
  return drained;
}, []);
Enter fullscreen mode Exit fullscreen mode

…and the scene spawns only from what it drained:

// web/src/three/RaceScene.ts

// --- exhaust: spawn ONLY from real token arrivals ---
// Each arrival carries the number of tokens that landed since the last
// frame. A stalled model produces no arrivals, so its plume stops growing.
if (s.arrivals.length > 0) {
  for (const arrival of s.arrivals) {
    const rt = s.lanes[arrival.lane];
    // One particle per token, plus a little extra while the rate is high so
    // a fast model reads as a denser plume rather than a dotted line.
    const density = 1 + Math.min(2, arrival.rate / 60);
    const count = Math.max(1, Math.round(arrival.count * density));
    this.spawnExhaust(arrival.lane, Math.min(count, 220), rt.tokens, arrival.rate);
  }
  s.arrivals.length = 0;
}
Enter fullscreen mode Exit fullscreen mode

Now the plume is a readout. Token arrival rate literally becomes particle spawn
rate. A fast model reads as a dense, hot plume; a stalling model goes quiet.

Plume size also scales with cumulative tokens, so the loser's trail is both longer and
fatter:

// Plume grows with cumulative tokens: more tokens -> fatter, longer-lived.
const plumeScale = 1 + Math.min(1.6, cumulativeTokens / 900);
const rateBoost  = 1 + Math.min(1.2, rate / 90);
Enter fullscreen mode Exit fullscreen mode

Doing it at 60fps

Thousands of particles per lane, spawned continuously, with zero per-frame allocation.

One THREE.Points per lane with a fixed 4000-particle pool and a ring-buffer
cursor. Dead particles aren't removed — they're pushed outside the clip volume in the
vertex shader and collapsed to zero size:

float age = uTime - aBirth;
float t = aLife > 0.0 ? age / aLife : 2.0;

if (t < 0.0 || t > 1.0) {
  // Dead particle: push it outside the clip volume and collapse it.
  gl_Position = vec4(0.0, 0.0, -2.0, 1.0);
  gl_PointSize = 0.0;
  vAlpha = 0.0;
  return;
}
Enter fullscreen mode Exit fullscreen mode

Recycling is a modulo on an integer cursor. The GPU does the ageing, the drift, the
expansion, and the fade — the CPU only writes new particles into the ring.


Four bugs that taught me something

1. The SSE stream that died after one event

This one cost me an embarrassing amount of time, and the fix is a single word.

The server was doing:

req.on('close', () => { closed = true; });
Enter fullscreen mode Exit fullscreen mode

In Node, req emits 'close' when the request body has been fully consumed — not
when the client disconnects. Since the POST body is tiny and arrives instantly, closed
flipped to true almost immediately, and my emit() guard silently swallowed every
event after start. The race looked like it hung. Nothing errored. Nothing logged.

The fix:

// NOTE: listen on the RESPONSE, not the request. `req` emits 'close' as soon
// as the POST body has been fully consumed, which would mark the stream dead
// before a single race event was written.
res.on('close', () => {
  closed = true;
  clearInterval(heartbeat);
});
Enter fullscreen mode Exit fullscreen mode

If you're building SSE on Express, this is the bug you will hit. Two more things worth
copying: a heartbeat comment every 15 seconds so proxies don't idle the connection out
during a long think, and headers that stop anything from buffering the stream:

res.writeHead(200, {
  'Content-Type': 'text/event-stream; charset=utf-8',
  'Cache-Control': 'no-cache, no-transform',
  Connection: 'keep-alive',
  'X-Accel-Buffering': 'no',
});
Enter fullscreen mode Exit fullscreen mode

2. EventSource can't POST

The browser's EventSource API only does GET. I need to send a prompt body. So the
client uses fetch plus a ReadableStream reader and parses SSE frames by hand:

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  // SSE frames are separated by a blank line.
  let sep: number;
  while ((sep = buffer.search(/\r?\n\r?\n/)) !== -1) {
    const frame = buffer.slice(0, sep);
    buffer = buffer.slice(sep + (buffer[sep] === '\r' ? 4 : 2));
    // …parse `event:` / `data:` lines, dispatch
  }
}
Enter fullscreen mode Exit fullscreen mode

Note the buffer-and-split: chunks arrive at arbitrary boundaries, so a frame can be
split mid-JSON. You must accumulate and only parse on the blank-line terminator. And
flush any trailing frame after the loop, because the last one may lack a terminator.

3. An empty response is a budget problem, not a refusal

A reasoning model can spend its entire max_tokens thinking and return empty
content
. To a naive client that looks like a broken model. It's actually a
misconfigured budget.

So: retry once with double the budget.

// Attempt 1, then (only if content came back empty) one retry with a
// doubled budget. An empty response is a budget problem, not a refusal.
for (let attempt = 1; attempt <= 2; attempt++) {
  attempts = attempt;
  maxTokensUsed = attempt === 1 ? config.maxTokens : Math.min(config.maxTokens * 2, 4000);
  // …
}
Enter fullscreen mode Exit fullscreen mode

The subtle part is drift. If the retry builds its own request, it will eventually
diverge from the first call — someone adds a field to one path and not the other. So
both attempts build from one shared factory:

// Shared callbacks so the retry path can never drift from the first call.
const attemptOptions = (): AttemptOptions => ({ /* … */ });
Enter fullscreen mode Exit fullscreen mode

4. Counting reasoning without ever reading it

This one is an ethics decision as much as a technical one.

Reasoning traces are the model's private scratchpad. They can contain the user's data,
half-formed (and wrong) intermediate conclusions, and content that was never meant to be
shown. I need the cost of reasoning. I do not need, want, or store its content.

So the server tests for presence, increments a counter, and throws the text away:

// COUNT reasoning, never capture it.
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
  reasoningChunks += 1;
  opts.onReasoning(reasoningChunks);
}
Enter fullscreen mode Exit fullscreen mode

There are exactly two references to reasoning_content in the entire server: that
line and the doc comment above it. The authoritative number comes from usage:

const rt = usage?.completion_tokens_details?.reasoning_tokens;
if (typeof rt === 'number') reasoningTokens = rt;
Enter fullscreen mode Exit fullscreen mode

If a provider omits usage, it falls back to the live chunk count — so the HUD never
shows a lie. And npm run ui-check asserts that reasoning_content never appears in
the DOM.


Proving it isn't a nice animation

The whole value of this project rests on one claim: it measures something real. So the
claim has to be falsifiable. I wrote three scripts, no test framework.

npm run verify

Drives the live endpoint and asserts the two presets produce visibly different
races:

━━━ DIVERGENCE ━━━
  ✓ lanes visibly separate (token gap > 20)          (gap = 38)
  ✓ slower model has the fatter plume                (loser 34 vs winner 21)
  ✓ outputs differ                                   (identical = false)
  ✓ reasoning token counts read from usage           (A=0 B=50)

━━━ TIE ━━━
  ✓ finish near-simultaneously (< 1500ms)            (gap = 0ms)
  ✓ IDENTICAL OUTPUT                                 (identical = true)

  ✓ the two prompts produced VISIBLY DIFFERENT races
Enter fullscreen mode Exit fullscreen mode

If both prompts ever produce identical-looking races, the app has stopped measuring
anything and this script says so.

npm run ui-check — and a measurement bug worth reading

Headless Playwright. It samples the HUD mid-race to prove counters advance live, and
analyses screenshot pixels to confirm the slower lane really renders the fatter plume.

My first version counted cyan and magenta pixels in the screenshot. It failed
constantly, and the reason is a good lesson in measuring things:

The glowing rails are the same colours as the rockets. Worse, lane A's rail sits
nearer the camera, so it's always bigger. The measurement was dominated by a static
piece of scenery, not the exhaust.

The fix: locate each lane's rail row (the densest run of that colour in its half of the
screen), then count only pixels above it — because exhaust drifts upward and the
rail does not.

run winner plume loser plume
1 1,868 px / 69 px tall 48,163 px / 172 px tall
2 4,039 px / 69 px tall 29,673 px / 143 px tall
3 2,264 px / 21 px tall 48,282 px / 135 px tall

Three consecutive runs, consistently ~10× difference. Now the test measures the thing it
claims to measure.

npm run mock

A fake OpenAI-compatible provider so the whole pipeline is testable offline: a "slow
thinker" that streams reasoning_content before content, a model that returns empty
content on its first call
so the retry path is observable, and a tie mode where both
models converge on identical text and finish together.

This is how I verified the retry, the reasoning counter, and the IDENTICAL OUTPUT
verdict without spending a cent.


What I'd tell you to build next

The single highest-impact addition would be a cost ticker. Reasoning tokens are
billed. Multiply by the model's real price and put "the tax cost $0.0043" next to the
plume. That converts a metaphor into a number a team can act on.

After that: run each prompt 5× and show the median and spread. One race is an
anecdote; five is a measurement.
A single race can mislead — sampling variance is
real, and the app should be honest about that.

And the most interesting experiment the architecture already supports: race the same
model with reasoning on versus off. The Disable reasoning flag is already there. The
quality delta is the honest value of the tax.


Try it

git clone https://github.com/harishkotra/reasoning-tax-race.git
cd reasoning-tax-race
npm install
cp .env.example server/.env     # paste your key
npm run dev                     # → http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

Then run Haiku about rain, and watch the magenta rocket lose the race and keep going.

Everything is measured live. Nothing is mocked. And if you find a race that surprises you, open an issue with the exported JSON — that's the most useful bug report this project can receive.

Code & more: https://www.dailybuild.xyz/project/253-reasoning-tax-race

Top comments (0)