---
title: "The 90th-Minute Race Condition: Building Reliable AI Agents with Live Sports Data"
description: "An older live-sports update can arrive after a newer one, leaving an AI agent with the wrong score. A runnable Node.js example shows the race condition and how version checks, duplicate handling, freshness limits, and reconciliation help prevent misleading answers."
author: "Bostjan Korosak"
author_url: "https://www.stackandskill.com/authors/bostjan-korosak"
published: 2026-10-29
format: deep-dive
tags: ["backend", "javascript", "ai-agents", "postgresql", "real-time-data"]
tested_with: ["Node.js 20.10.0", "Node.js 22.16.0", "PostgreSQL"]
verified: 2026-10-29
reading_time: 12 min
related: ["https://www.stackandskill.com/blog/what-happens-when-eight-workers-share-one-odds-api-key", "https://www.stackandskill.com/blog/database-indexing-explained", "https://www.stackandskill.com/blog/a-browser-image-exporter-that-checks-what-it-actually-saved"]
url: "https://www.stackandskill.com/blog/the-90th-minute-race-condition-building-reliable-ai-agents-with-live-sports-data"
cite: "Korosak, B. (2026). The 90th-Minute Race Condition: Building Reliable AI Agents with Live Sports Data. Stack & Skill. https://www.stackandskill.com/blog/the-90th-minute-race-condition-building-reliable-ai-agents-with-live-sports-data"
---

# The 90th-Minute Race Condition: Building Reliable AI Agents with Live Sports Data

# The 90th-Minute Race Condition: Building Reliable AI Agents with Live Sports Data

*An old match snapshot can arrive after a new one. If an AI agent uses whichever response finished last, it can confidently report the wrong score. Here's a small reproduction and a more defensible way to handle it.*

Imagine a match in stoppage time. The score is 1–1. A live-data service requests the latest state and, before that request finishes, sends another request. The second request sees a late goal: 2–1. It returns first. The first request returns afterwards, still carrying 1–1.

If the application treats the latest *arrival* as the latest *match state*, the score goes backwards. Nothing has to crash. Both requests can return HTTP 200. The JSON can be perfectly valid. The AI agent receives a plausible-looking number and answers with confidence.

This is an example, not a report of a real match or an incident at a particular provider. But the engineering failure is ordinary: two asynchronous operations complete in a different order from the changes they describe.

The interesting part isn't making a language model say “2–1.” It's proving that the data we put in front of it is the best state we can establish, and making the model say less when we cannot establish that state.

## The two clocks that cause the problem

A live-sports integration usually has at least two clocks. One belongs to the sporting event: goals, corrections and status changes occur in some logical order. The other belongs to the application: requests start, responses arrive, queues retry, caches refresh and workers commit changes.

Those clocks aren't interchangeable.

Consider two snapshots of the same fictional match:

| Snapshot | Source revision | Score | Request completion |
| --- | ---: | --- | --- |
| A | 41 | 1–1 | Arrives after B |
| B | 42 | 2–1 | Arrives before A |

A naive consumer writes B, then overwrites it with A. The database now holds an older view because an older request finished last. A model that subsequently asks “what's the score?” inherits that mistake.

This can happen with parallel polling, two API regions with different update lag, a webhook retried after a transient failure, or a delayed queue consumer. It is not specific to sports or AI. AWS, for example, documents that [EventBridge does not guarantee event ordering](https://docs.aws.amazon.com/decision-guides/latest/decision-guides/sns-or-sqs-or-eventbridge.html). Treating arrival order as business truth is risky even when the transport is reliable.

There is another, less obvious race: the agent can fetch a correct snapshot, spend several seconds generating an answer, and publish it after the game has changed again. Solving the write-order problem helps, but it does not freeze the match while the model speaks.

For a broader look at retries, event ordering, and idempotent consumers, see [Microservices Communication: Synchronous vs. Asynchronous Patterns](https://www.stackandskill.com/blog/microservices-communication-patterns).

## Reproduce the wrong answer before fixing it

The following is a self-contained **Node.js 20+** example. It needs no packages and no API key. The match, scores and revision numbers are synthetic. They are deliberately versioned to illustrate the ordering rule; **not every sports-data provider supplies such a revision**.

Save the code as `live-match-race.mjs` and run `node live-match-race.mjs`.

```js
// Run: node live-match-race.mjs (Node.js 20+)
import assert from 'node:assert/strict';
import { isDeepStrictEqual } from 'node:util';
import { setTimeout as sleep } from 'node:timers/promises';

const matchId = 'example-match';
const slowSnapshot = { matchId, revision: 41, score: '1-1', minute: '90+1' };
const fastSnapshot = { matchId, revision: 42, score: '2-1', minute: '90+2' };

let naiveState;
const matches = new Map();

function ingest(snapshot, receivedAt = Date.now()) {
  if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) {
    throw new Error('An ordered, nonnegative revision is required');
  }

  const current = matches.get(snapshot.matchId);
  if (current && snapshot.revision < current.revision) return 'stale_update';

  if (current && snapshot.revision === current.revision) {
    const { receivedAt, ...stored } = current;
    return isDeepStrictEqual(stored, snapshot) ? 'duplicate' : 'revision_conflict';
  }

  matches.set(snapshot.matchId, { ...snapshot, receivedAt });
  return 'applied';
}

async function fetchAndHandle(snapshot, latencyMs) {
  await sleep(latencyMs); // Simulate independent requests completing out of order.
  naiveState = snapshot; // Wrong: last response to arrive wins.
  console.log(`revision ${snapshot.revision}: ${ingest(snapshot)}`);
}

await Promise.all([
  fetchAndHandle(slowSnapshot, 40),
  fetchAndHandle(fastSnapshot, 10),
]);

assert.equal(naiveState.score, '1-1');
assert.equal(matches.get(matchId).score, '2-1');
assert.equal(ingest(fastSnapshot), 'duplicate');
assert.equal(ingest({ ...fastSnapshot, score: '3-1' }), 'revision_conflict');
assert.equal(ingest({ ...fastSnapshot, status: 'finished' }), 'revision_conflict');

function readForAgent(id, now = Date.now(), maxAgeMs = 5000) {
  const record = matches.get(id);
  if (!record) return { status: 'unavailable' };
  const ageMs = now - record.receivedAt;
  if (ageMs < 0 || ageMs > maxAgeMs) {
    return { status: 'stale', lastRevision: record.revision, ageMs };
  }
  return {
    status: 'fresh',
    score: record.score,
    minute: record.minute,
    revision: record.revision,
    ageMs,
  };
}

assert.equal(readForAgent(matchId).status, 'fresh');
assert.equal(readForAgent(matchId, Date.now() + 6000).status, 'stale');
console.log(`naive: ${naiveState.score}; guarded: ${matches.get(matchId).score}`);
console.log('duplicate, conflict, and freshness checks passed');
```

On a normal run, revision 42 is applied first, and revision 41 is rejected as stale. The intentionally naive variable ends at 1–1; the guarded store remains at 2–1. The assertions then check three conditions: replaying revision 42 doesn't change the state; reusing revision 42 with different contents, including a changed match status, is flagged as a conflict; and a snapshot older than the allowed window is not passed off as fresh. For the conflict check, we compare the full example snapshot after removing the locally added `receivedAt` field. In a larger system, normalize the domain fields first so irrelevant transport metadata does not create false conflicts.

The `40` and `10` in the demo are artificial delays, not measurements of any sports feed. The test shows a possible ordering failure, not how often it occurs in production. [Node's timer documentation](https://nodejs.org/docs/latest-v20.x/api/timers.html) also notes that timers do not guarantee exact scheduling. If you turn this into a CI test, use an explicitly controlled promise or queue to force completion order rather than relying on millisecond timing.

The important line is the revision comparison. The rest of the example makes it observable.

## A revision is useful only if it means something

It would be easy to look at that code and declare the problem solved: assign every response a number and keep the largest. But where does the number come from?

A revision assigned by the *consumer when a response arrives* is worthless for this purpose. It merely records the same unreliable arrival order in a nicer format. A local timestamp has a similar weakness. `received_at` tells you when your service received a payload, not when that payload became true in the source system.

A usable ordering key must come from an authority that knows the order of state changes. That could be a source-provided sequence number, a documented per-match revision, or a canonical event log you control. The contract matters: is the number monotonic for one match? Can a correction increase it? Does it reset between fixtures or feeds? Is it scoped to a provider, competition or match?

In the demo, revision 42 outranks 41 because we explicitly defined it that way. In a real integration, **do not assume that an `updated_at` timestamp or numeric ID carries the same guarantee** without checking the provider's documentation.

If the feed provides no trustworthy ordering key, the honest fallback is reconciliation. On suspicious or conflicting updates, fetch an authoritative current snapshot, mark the local record as uncertain until the check completes, and avoid issuing a precise “live” answer based solely on whichever update arrived last. Even reconciliation can read a lagging replica, so it should be paired with source-specific freshness expectations and repeated checks when needed.

Corrections make this more important. A goal can be disallowed, a scorer changed, or a fixture status revised. A correctly designed versioning scheme must allow the source to move from 2–1 back to 1–1 *as a newer correction*. “Scores must only increase” is not a safe invariant. “A newer authoritative revision wins” is a much better one.

## The in-memory guard is not a production design

The example uses a JavaScript `Map` because it isolates the rule. It would not protect an application running several workers or containers. Two processes can read revision 41, independently make decisions, and then race to write their results into a shared database.

In production, move the comparison to an atomic database operation. A row keyed by `(provider, match_id)` can be updated only if the incoming authoritative revision exceeds the stored revision. For example, PostgreSQL can enforce this with a conditional upsert:

```sql
CREATE TABLE match_state (
  provider text NOT NULL,
  match_id text NOT NULL,
  revision bigint NOT NULL,
  score text NOT NULL,
  minute text NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, match_id)
);

INSERT INTO match_state (provider, match_id, revision, score, minute)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (provider, match_id) DO UPDATE
SET revision = EXCLUDED.revision,
    score = EXCLUDED.score,
    minute = EXCLUDED.minute,
    received_at = now()
WHERE match_state.revision < EXCLUDED.revision
RETURNING revision;
```

The placeholders represent application parameters. A newer revision updates the row, even if the score decreases after an official correction. A stale or equal revision leaves it untouched. When `RETURNING` yields no row, the write was skipped; **that result alone does not distinguish an old update from an exact duplicate or a conflicting same-revision payload**. Detect and investigate those cases separately, using a normalized snapshot comparison or an event log. This is an atomic write, unlike a read-then-write check performed by each worker.

There are operational costs. You need a stable identity for each fixture; competition IDs alone aren't enough. You need to separate providers if their revision schemes are unrelated. You need a way to replay messages or restore the current snapshot after a worker outage. And if you retain every event rather than only the latest state, storage and replay procedures become part of the design.

A snapshot table is often enough for a read-only match assistant. An event log becomes more valuable when you need an audit trail, correction history, or multiple consumers with different interpretations of the same event. Neither model fixes a poor upstream ordering contract.

## Put a freshness contract between the data and the agent

A language model should not be asked to infer freshness from a score string. Give it a tool response that distinguishes *what is known* from *how recently it was checked*.

The `readForAgent()` function returns either a fresh snapshot with its revision and age, or a stale/unavailable status without a score to quote as current. That is a deliberate interface choice. It prevents a stale cache hit from becoming a fluent sentence such as “The current score is 1–1.”

There is an important limitation: `ageMs` in the example measures time since **our service received** the update. It does not prove the upstream provider was current. A snapshot pulled from a delayed source can look fresh locally. Where available, keep the provider's observed timestamp and the ingestion timestamp separately, and report both internally.

The agent-side rules can be plain:

- If the tool says `fresh`, answer with the score and an “as of” time supplied by the tool. Do not claim the answer will remain current.
- If it says `stale`, try a bounded refresh. If that fails, say the latest score cannot be verified right now.
- If sources disagree, do not blend their scores into a confident single answer. Prefer the designated source of truth, or disclose the disagreement.
- If a user asks a consequential question about a live outcome, re-check immediately before answering. Even then, the answer is a snapshot, not a promise about the next second.

For long-running agent workflows, you can check the revision again after generation. If it changed, discard or regenerate the response. That creates more requests and can increase latency, so apply it selectively—especially near the end of a close game. A permanently live answer isn't achievable over an ordinary request-response channel; an accurately timestamped answer is.

## Test what happens when the network behaves badly

A successful happy-path integration proves remarkably little. I would test the following cases before trusting an agent with live match updates:

1. **Reordered completion.** Two requests start in sequence but finish in reverse. The older snapshot must not overwrite the newer one.
2. **Duplicate delivery.** A retry sends the same revision twice. State changes once; side effects are not repeated.
3. **Conflicting same-revision data.** Two payloads use the same revision but disagree. Surface a conflict and reconcile instead of picking one arbitrarily.
4. **Authorized correction.** A newer revision reduces the score or changes the match status. Accept it because the revision is newer, not because the score moves in a preferred direction.
5. **Expired freshness.** The feed pauses. The agent stops claiming the cached result is live, even though the database still has a valid JSON record.
6. **Worker overlap.** Two workers process revisions concurrently. The database conditional write, not an in-memory comparison, decides which persists.
7. **Source disagreement.** Two upstreams publish different states. Don't compare their sequence numbers as though they came from the same counter.

Metrics should reflect those cases. I would track the age of the last authoritative update per match, rejected older revisions, equal-revision conflicts, retries, time spent without fresh data, and the number of responses where the agent had to say “unverified.” An increasing count of rejected stale updates may mean the guard is working; it may also reveal a provider or queue getting slower. The raw count needs context.

One failure deserves special attention: the consumer can become perfectly ordered and permanently outdated. If updates stop arriving, it may never see a higher revision to reject. That is why monitoring age and reconciliation is as important as the comparison operator.

## Accuracy has a latency price

There isn't one universal freshness threshold for live sports. A five-second cache might be acceptable for a casual conversational summary and unacceptable for a last-play notification. A low-frequency youth fixture feed may not update at anything close to the frequency of a top-tier professional match. Source latency, polling limits and match phase all matter.

Checking the provider before every token is neither feasible nor useful. Fetching too often can also run into quotas, increase costs and create even more overlapping requests. A sensible design sets freshness targets per use case, coordinates in-flight refreshes for the same match, and uses short-lived cached snapshots with an explicit expiry.

The final decision is a product decision as much as an engineering one: when the data isn't trustworthy enough, should the agent answer from a known-but-old snapshot, ask the user to wait, or decline to provide a live score? For an assistant, a visible “last confirmed at 90+1” is often better than an authoritative-sounding score that may already have changed.

## The rule I would keep

A live-data agent does not become reliable merely because the underlying API is reliable, and it does not become unreliable simply because it uses an LLM. The weak point is often the boundary between asynchronous updates and a sentence presented as fact.

Preserve authoritative ordering when you have it. Reconcile when you don't. Keep freshness separate from correctness. And give the model permission to say it cannot verify a live state.

At the 90th minute, a little uncertainty is usually less damaging than a beautifully written wrong answer.

---

**Author disclosure:** The author is associated with Modulout, a company that develops sports software and data integrations. The match and request timings in this article are synthetic; the example does not describe a production incident or SportStatsAPI's actual API schema.

**AI assistance:** ChatGPT assisted with research, drafting, editing and preparation of the demonstration code. The author reviewed the final text and ran the code before submission.
