DEV Community

Miheve
Miheve

Posted on

My Football App Worked Perfectly Until Matchday Started

Building a football scores app looked easy when I first planned it.

Fetch today's matches.

Display the teams.

Show the score.

Refresh the page every few seconds.

That was the entire architecture.

During development, it worked perfectly. I tested it with a few sample matches, opened the page in two browser tabs, and watched the scores update.

Then the first busy matchday arrived.

Several games started at the same time. Hundreds of users opened the app. Requests began overlapping. Some scores appeared to move backward. Finished matches continued refreshing, and the same football data was fetched separately for every visitor.

The API was working correctly.

The frontend was rendering what it received.

The problem was everything between them.

That day taught me that a football livescore app is not simply a website connected to an API.

It is a live data synchronization system.


The First Version

My first implementation was a client-side component with a polling interval.

"use client"

import { useEffect, useState } from "react"

type Match = {
  id: string
  homeTeam: string
  awayTeam: string
  homeScore: number
  awayScore: number
}

export default function LiveMatches() {
  const [matches, setMatches] = useState<Match[]>([])

  useEffect(() => {
    async function fetchMatches() {
      const response = await fetch("/api/matches/live")
      const data = await response.json()

      setMatches(data.matches)
    }

    fetchMatches()

    const intervalId = window.setInterval(
      fetchMatches,
      5000
    )

    return () => {
      window.clearInterval(intervalId)
    }
  }, [])

  return (
    <div>
      {matches.map((match) => (
        <article key={match.id}>
          <span>{match.homeTeam}</span>

          <strong>
            {match.homeScore} - {match.awayScore}
          </strong>

          <span>{match.awayTeam}</span>
        </article>
      ))}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

It looked reasonable.

Every five seconds, the app requested the newest scores and replaced the current state.

The problem became obvious when the number of users increased.

1 user      = 12 requests per minute
100 users   = 1,200 requests per minute
1,000 users = 12,000 requests per minute
Enter fullscreen mode Exit fullscreen mode

Most of those requests were asking for exactly the same data.

Every browser was independently fetching the same matches.

The app had no shared cache, no request coordination, and no understanding of whether the matches could still change.


A Live Score Does Not Need to Be Fetched Separately for Every User

When two users open the same match page, they usually need the same score.

The score is not personalized.

That means the server should be able to reuse a recently fetched result instead of requesting the football provider again for every visitor.

A basic server-side request function might look like this:

// lib/football-api.ts

import "server-only"

const baseUrl = process.env.FOOTBALL_API_URL
const apiKey = process.env.FOOTBALL_API_KEY

if (!baseUrl) {
  throw new Error("FOOTBALL_API_URL is missing")
}

if (!apiKey) {
  throw new Error("FOOTBALL_API_KEY is missing")
}

export async function footballRequest<T>(
  endpoint: string,
  revalidate = 15
): Promise<T> {
  const response = await fetch(
    `${baseUrl}${endpoint}`,
    {
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${apiKey}`,
      },
      next: {
        revalidate,
      },
    }
  )

  if (!response.ok) {
    throw new Error(
      `Football API request failed with ${response.status}`
    )
  }

  return response.json() as Promise<T>
}
Enter fullscreen mode Exit fullscreen mode

The important part is not the exact URL or authentication format.

The important part is that the external API request happens on the server.

The browser communicates with your application.

Your application communicates with the football data provider.

This gives you control over:

  • API credentials
  • Caching
  • Error handling
  • Rate limiting
  • Response formatting
  • Logging

Never Expose the Football API Key in the Browser

This is unsafe:

const response = await fetch(
  `https://example.com/live?key=${process.env.NEXT_PUBLIC_FOOTBALL_API_KEY}`
)
Enter fullscreen mode Exit fullscreen mode

Variables prefixed with NEXT_PUBLIC_ can be included in client-side JavaScript.

Anyone using the application may be able to inspect the request and copy the key.

Instead, keep the key in a private environment variable:

FOOTBALL_API_URL=https://api.example.com
FOOTBALL_API_KEY=your_private_key
Enter fullscreen mode Exit fullscreen mode

Then import the API client only from server-side files.

import "server-only"
Enter fullscreen mode Exit fullscreen mode

A football app can generate a large number of requests during popular matches. An exposed key can quickly become expensive.


Do Not Use the Raw API Response Everywhere

My next mistake was passing the provider response directly into React components.

The component expected fields such as:

<p>{event.home_team.name}</p>
<p>{event.current_score.home}</p>
<p>{event.match_status}</p>
Enter fullscreen mode Exit fullscreen mode

That created a strong dependency between the UI and one specific response format.

When one endpoint returned homeTeam instead of home_team, I had to add special cases inside the component.

A better approach is to define an internal match type.

// types/match.ts

export type MatchStatus =
  | "scheduled"
  | "live"
  | "halftime"
  | "finished"
  | "postponed"
  | "cancelled"

export type Team = {
  id: string
  name: string
  logoUrl: string | null
}

export type FootballMatch = {
  id: string
  competition: {
    id: string
    name: string
    country: string | null
  }
  homeTeam: Team
  awayTeam: Team
  score: {
    home: number | null
    away: number | null
  }
  status: MatchStatus
  minute: number | null
  startsAt: string
}
Enter fullscreen mode Exit fullscreen mode

Then map the provider response into the application's format.

// lib/map-match.ts

import type {
  FootballMatch,
  MatchStatus,
} from "@/types/match"

type ApiMatch = {
  id: string | number
  status?: string
  minute?: number | null
  start_time?: string
  league?: {
    id?: string | number
    name?: string
    country?: string
  }
  home?: {
    id?: string | number
    name?: string
    logo?: string
  }
  away?: {
    id?: string | number
    name?: string
    logo?: string
  }
  scores?: {
    home?: number | null
    away?: number | null
  }
}

export function mapMatch(
  match: ApiMatch
): FootballMatch {
  return {
    id: String(match.id),
    competition: {
      id: String(match.league?.id ?? "unknown"),
      name: match.league?.name ?? "Unknown competition",
      country: match.league?.country ?? null,
    },
    homeTeam: {
      id: String(match.home?.id ?? "unknown-home"),
      name: match.home?.name ?? "Home team",
      logoUrl: match.home?.logo ?? null,
    },
    awayTeam: {
      id: String(match.away?.id ?? "unknown-away"),
      name: match.away?.name ?? "Away team",
      logoUrl: match.away?.logo ?? null,
    },
    score: {
      home: match.scores?.home ?? null,
      away: match.scores?.away ?? null,
    },
    status: normalizeStatus(match.status),
    minute: match.minute ?? null,
    startsAt:
      match.start_time ?? new Date().toISOString(),
  }
}

function normalizeStatus(
  status?: string
): MatchStatus {
  switch (status?.toLowerCase()) {
    case "live":
    case "first_half":
    case "second_half":
    case "in_progress":
      return "live"

    case "half_time":
    case "halftime":
      return "halftime"

    case "full_time":
    case "finished":
    case "ended":
      return "finished"

    case "postponed":
      return "postponed"

    case "cancelled":
    case "canceled":
      return "cancelled"

    default:
      return "scheduled"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the components depend on a stable application model.

If the external API changes, only the mapping layer needs to change.


Render the First Match List on the Server

My first app displayed an empty loading screen until the browser completed its first request.

That was unnecessary.

With Next.js Server Components, the initial matches can be loaded before the page reaches the browser.

// app/page.tsx

import { MatchCenter } from "@/components/match-center"
import { getMatchesByDate } from "@/lib/get-matches"

export default async function HomePage() {
  const date = new Date()
    .toISOString()
    .slice(0, 10)

  const matches = await getMatchesByDate(date)

  return (
    <main>
      <header>
        <p>Football scores</p>
        <h1>Today's Matches</h1>
      </header>

      <MatchCenter
        date={date}
        initialMatches={matches}
      />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

The server-side data function can apply a short cache duration.

// lib/get-matches.ts

import "server-only"

import { footballRequest } from "@/lib/football-api"
import { mapMatch } from "@/lib/map-match"
import type { FootballMatch } from "@/types/match"

type MatchesResponse = {
  matches: unknown[]
}

export async function getMatchesByDate(
  date: string
): Promise<FootballMatch[]> {
  const response =
    await footballRequest<MatchesResponse>(
      `/matches?date=${encodeURIComponent(date)}`,
      30
    )

  return response.matches.map((match) =>
    mapMatch(match as never)
  )
}
Enter fullscreen mode Exit fullscreen mode

The visitor sees content immediately.

The client-side code only needs to keep that content updated.


Use Your Own Route Handler for Updates

The browser should not need to know which provider powers the football data.

Create an internal endpoint.

// app/api/matches/route.ts

import { NextRequest, NextResponse } from "next/server"
import { getMatchesByDate } from "@/lib/get-matches"

export async function GET(
  request: NextRequest
) {
  const date =
    request.nextUrl.searchParams.get("date")

  if (!date || !isValidDate(date)) {
    return NextResponse.json(
      {
        error:
          "A valid date in YYYY-MM-DD format is required",
      },
      {
        status: 400,
      }
    )
  }

  try {
    const matches = await getMatchesByDate(date)

    return NextResponse.json(
      {
        matches,
        updatedAt: new Date().toISOString(),
      },
      {
        headers: {
          "Cache-Control":
            "public, s-maxage=10, stale-while-revalidate=20",
        },
      }
    )
  } catch (error) {
    console.error(
      "Failed to load football matches",
      error
    )

    return NextResponse.json(
      {
        error:
          "Football data is temporarily unavailable",
      },
      {
        status: 503,
      }
    )
  }
}

function isValidDate(value: string): boolean {
  return /^\d{4}-\d{2}-\d{2}$/.test(value)
}
Enter fullscreen mode Exit fullscreen mode

This endpoint becomes the boundary between the browser and the football provider.

Later, you can add:

  • Rate limiting
  • Authentication
  • Analytics
  • Provider fallback
  • Region-specific caching
  • Subscription checks
  • Request tracing

Stop Polling When Nothing Is Live

This was the easiest improvement and one of the most valuable.

My original app refreshed the match list every five seconds, even when every match was finished.

A finished score is unlikely to change.

A match scheduled for tomorrow does not need to be requested every few seconds.

The polling logic should depend on the match state.

// components/match-center.tsx

"use client"

import {
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react"

import type { FootballMatch } from "@/types/match"
import { MatchList } from "@/components/match-list"

type MatchCenterProps = {
  date: string
  initialMatches: FootballMatch[]
}

export function MatchCenter({
  date,
  initialMatches,
}: MatchCenterProps) {
  const [matches, setMatches] =
    useState(initialMatches)

  const [lastUpdated, setLastUpdated] =
    useState<string | null>(null)

  const [error, setError] =
    useState<string | null>(null)

  const currentRequest =
    useRef<AbortController | null>(null)

  const hasLiveMatches = matches.some(
    (match) =>
      match.status === "live" ||
      match.status === "halftime"
  )

  const refreshMatches = useCallback(
    async () => {
      currentRequest.current?.abort()

      const controller = new AbortController()
      currentRequest.current = controller

      try {
        const response = await fetch(
          `/api/matches?date=${encodeURIComponent(date)}`,
          {
            cache: "no-store",
            signal: controller.signal,
          }
        )

        if (!response.ok) {
          throw new Error(
            `Request failed with ${response.status}`
          )
        }

        const data = await response.json()

        setMatches(data.matches)
        setLastUpdated(data.updatedAt)
        setError(null)
      } catch (error) {
        if (
          error instanceof DOMException &&
          error.name === "AbortError"
        ) {
          return
        }

        console.error(error)

        setError(
          "Live updates are temporarily delayed."
        )
      }
    },
    [date]
  )

  useEffect(() => {
    if (!hasLiveMatches) {
      return
    }

    const intervalId = window.setInterval(
      refreshMatches,
      15_000
    )

    return () => {
      window.clearInterval(intervalId)
      currentRequest.current?.abort()
    }
  }, [hasLiveMatches, refreshMatches])

  return (
    <section>
      {lastUpdated && (
        <small>
          Updated{" "}
          {new Date(
            lastUpdated
          ).toLocaleTimeString()}
        </small>
      )}

      {error && (
        <p role="status">{error}</p>
      )}

      <MatchList matches={matches} />
    </section>
  )
}
Enter fullscreen mode Exit fullscreen mode

The important check is:

if (!hasLiveMatches) {
  return
}
Enter fullscreen mode Exit fullscreen mode

When no match is live, the refresh interval is not created.


Old Requests Can Overwrite New Scores

Polling introduces a race condition that is easy to miss.

Imagine these two requests:

Request A starts at 14:30:00
Request B starts at 14:30:15

Request B returns first with a score of 2-1
Request A returns later with a score of 1-1
Enter fullscreen mode Exit fullscreen mode

If both responses update the same state, the app shows the older score after the newer one.

From the user's perspective, the score moves backward.

This does not necessarily mean the football API returned incorrect data.

It may mean the responses arrived in the wrong order.

That is why the component aborts the previous request before starting a new one.

currentRequest.current?.abort()

const controller = new AbortController()
currentRequest.current = controller
Enter fullscreen mode Exit fullscreen mode

The older request is no longer allowed to overwrite the newest result.


Do Not Delete Good Data Because One Refresh Failed

My first error handler replaced the match list with an empty array.

That was a bad decision.

A score that is fifteen seconds old is usually more useful than no score at all.

When a background refresh fails, keep the last successful data visible.

try {
  const latestMatches = await loadMatches()

  setMatches(latestMatches)
  setError(null)
} catch {
  setError(
    "Updates are delayed. Showing the latest available scores."
  )
}
Enter fullscreen mode Exit fullscreen mode

The UI should communicate that the data may be stale, but it should not destroy useful information.

A live football app has more states than just loading, success, and error.

Initial loading
Fresh data
Refreshing
Temporarily stale data
Partial data
Provider unavailable
Recovered
Enter fullscreen mode Exit fullscreen mode

Designing for these states makes the app feel much more stable.


Match Status Should Control Behavior

At first, I treated match status as a label.

<span>{match.status}</span>
Enter fullscreen mode Exit fullscreen mode

But status is not only presentation data.

It should control the application.

For example:

// lib/match-status.ts

import type { FootballMatch } from "@/types/match"

export function isLive(
  match: FootballMatch
): boolean {
  return (
    match.status === "live" ||
    match.status === "halftime"
  )
}

export function isFinished(
  match: FootballMatch
): boolean {
  return match.status === "finished"
}

export function canStillChange(
  match: FootballMatch
): boolean {
  return (
    match.status === "scheduled" ||
    match.status === "live" ||
    match.status === "halftime"
  )
}
Enter fullscreen mode Exit fullscreen mode

These helpers can determine:

  • Whether polling should continue
  • Whether the minute should be shown
  • Whether kickoff time should be displayed
  • Whether the score is final
  • Whether notifications can still be triggered
  • Whether standings may need updating

As the app grows, you may need additional states such as:

Extra time
Penalties
Interrupted
Abandoned
Delayed
Awarded
Enter fullscreen mode Exit fullscreen mode

Normalizing those states early prevents status checks from spreading throughout the codebase.


Group Matches by Competition

A flat list works when there are five matches.

It becomes difficult to read when there are fifty.

Group the matches by league or competition before rendering them.

// lib/group-matches.ts

import type { FootballMatch } from "@/types/match"

type CompetitionGroup = {
  id: string
  name: string
  country: string | null
  matches: FootballMatch[]
}

export function groupMatchesByCompetition(
  matches: FootballMatch[]
): CompetitionGroup[] {
  const groups = new Map<
    string,
    CompetitionGroup
  >()

  for (const match of matches) {
    const competitionId =
      match.competition.id

    const existing =
      groups.get(competitionId)

    if (existing) {
      existing.matches.push(match)
      continue
    }

    groups.set(competitionId, {
      id: competitionId,
      name: match.competition.name,
      country: match.competition.country,
      matches: [match],
    })
  }

  return Array.from(groups.values())
}
Enter fullscreen mode Exit fullscreen mode

Then render each competition separately.

// components/match-list.tsx

import { groupMatchesByCompetition } from "@/lib/group-matches"
import type { FootballMatch } from "@/types/match"
import { MatchRow } from "@/components/match-row"

type MatchListProps = {
  matches: FootballMatch[]
}

export function MatchList({
  matches,
}: MatchListProps) {
  const groups =
    groupMatchesByCompetition(matches)

  if (groups.length === 0) {
    return (
      <p>No matches are available.</p>
    )
  }

  return (
    <div>
      {groups.map((group) => (
        <section key={group.id}>
          <header>
            {group.country && (
              <span>{group.country}</span>
            )}

            <h2>{group.name}</h2>
          </header>

          <div>
            {group.matches.map((match) => (
              <MatchRow
                key={match.id}
                match={match}
              />
            ))}
          </div>
        </section>
      ))}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The data structure now supports the interface instead of forcing every component to repeat the same transformation.


A Simple Match Row

Once the data is normalized, the UI becomes much easier to write.

// components/match-row.tsx

import type { FootballMatch } from "@/types/match"
import { isLive } from "@/lib/match-status"

type MatchRowProps = {
  match: FootballMatch
}

export function MatchRow({
  match,
}: MatchRowProps) {
  return (
    <article>
      <div>
        <span>{match.homeTeam.name}</span>

        {match.homeTeam.logoUrl && (
          <img
            src={match.homeTeam.logoUrl}
            alt=""
            width={24}
            height={24}
          />
        )}
      </div>

      <div>
        {match.status === "scheduled" ? (
          <time dateTime={match.startsAt}>
            {new Date(
              match.startsAt
            ).toLocaleTimeString([], {
              hour: "2-digit",
              minute: "2-digit",
            })}
          </time>
        ) : (
          <strong>
            {match.score.home ?? 0}
            {" - "}
            {match.score.away ?? 0}
          </strong>
        )}

        {isLive(match) && (
          <span>
            {match.minute
              ? `${match.minute}'`
              : "LIVE"}
          </span>
        )}
      </div>

      <div>
        {match.awayTeam.logoUrl && (
          <img
            src={match.awayTeam.logoUrl}
            alt=""
            width={24}
            height={24}
          />
        )}

        <span>{match.awayTeam.name}</span>
      </div>
    </article>
  )
}
Enter fullscreen mode Exit fullscreen mode

The component does not know anything about the external provider.

It only understands the application's FootballMatch type.


Caching Does Not Mean Showing Old Scores for Minutes

Developers sometimes avoid caching live data because they assume a cache will make the app feel outdated.

That depends on the cache duration.

A short shared cache can reduce duplicate requests without creating a noticeable delay.

For example:

Browser refresh interval:       15 seconds
Server cache duration:          10 seconds
Stale-while-revalidate window:  20 seconds
Enter fullscreen mode Exit fullscreen mode

Users still receive frequent updates.

But if hundreds of users request the same match data within a short period, the server can reuse the result.

Without shared caching:

500 users
500 external API requests
Enter fullscreen mode Exit fullscreen mode

With a short server cache:

500 users
A much smaller number of external API requests
Enter fullscreen mode Exit fullscreen mode

Live does not always mean uncached.

It often means briefly cached and frequently refreshed.


The Final Request Flow

After restructuring the application, one visit follows this flow:

User opens the football app

Server Component
  Fetches today's matches
  Uses a short shared cache
  Maps the provider response
  Renders the initial page

Browser
  Displays matches immediately
  Checks whether any match is live

If a match is live
  Starts a refresh interval
  Requests updates from the internal API route

Route Handler
  Validates the date
  Reuses recent shared data when possible
  Calls the football provider when needed
  Returns normalized match objects

Client Component
  Cancels the previous request
  Applies the newest result
  Keeps old data if refreshing fails
  Stops polling when all matches finish
Enter fullscreen mode Exit fullscreen mode

Every layer now has a clear responsibility.


The Mental Model That Helped Me

The football provider supplies the raw data.

The server protects, caches, validates, and normalizes it.

The browser renders the data and requests updates only while they are useful.

That is the architecture I wish I had used from the beginning.

My first version was not wrong because it used polling.

It was wrong because polling was the entire architecture.

There was no protection against duplicate requests, stale responses, race conditions, provider changes, or finished matches that could no longer change.


What I Would Add Next

Once the live match flow is stable, the same foundation can support:

  • Match detail pages
  • Goals, cards, and substitutions
  • Starting lineups
  • League standings
  • Team pages
  • Player statistics
  • Favorite teams
  • Goal notifications
  • Match search
  • User time zones
  • WebSocket updates
  • Server-Sent Events
  • Multiple sports

The important part is keeping the same data flow:

External provider
  ↓
Server-side client
  ↓
Mapping layer
  ↓
Application data functions
  ↓
Route Handler or Server Component
  ↓
User interface
Enter fullscreen mode Exit fullscreen mode

When these boundaries remain clear, adding new football features becomes much easier.


Final Lesson

The visible part of a football app is simple.

Two teams.

One score.

One match clock.

The difficult part is making sure every user sees the newest correct version without generating unnecessary requests or exposing private credentials.

A demo only needs to display football data.

A real football product needs to manage:

  • Live updates
  • Shared caching
  • Request ordering
  • Match states
  • Provider failures
  • Stale data
  • API security
  • Response normalization
  • Rendering performance

My app worked perfectly before matchday because it had never experienced matchday conditions.

The busy afternoon did not break the application.

It revealed the application I had actually built.

Have you ever built a live sports app? What was the first problem that only appeared after real users arrived?

Top comments (0)