VOOZH about

URL: https://dev.to/mihailove123/my-football-app-worked-perfectly-until-matchday-started-3i59

⇱ My Football App Worked Perfectly Until Matchday Started - DEV Community


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>
 )
}

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

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>
}

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}`
)

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

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

import "server-only"

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>

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
}

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"
 }
}

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>
 )
}

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)
 )
}

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)
}

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>
 )
}

The important check is:

if (!hasLiveMatches) {
 return
}

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

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

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."
 )
}

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

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>

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"
 )
}

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

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())
}

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>
 )
}

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>
 )
}

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

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

With a short server cache:

500 users
A much smaller number of external API requests

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

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

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?