How to Design a Live Sports Scoring & Stats Platform
Table of Contents
- Problem Overview & Scope
- Functional & Non-Functional Requirements
- High-Level Architecture
- Real-Time Score Ingestion Pipeline
- Match Event Modeling
- Sport-Specific Schemas
- Live Match Timeline
- Push Notifications & Score Alerts
- WebSocket Streaming to Clients
- Match Statistics Aggregation
- Historical Data & Analytics
- Fixture Scheduling & Calendar
- League Standings & Tables
- Player Profiles & Season Stats
- Fantasy Sports Integration
- Odds & Betting Data Feed
- Highlight Clips & Video Integration
- Social Media Integration
- Multi-Sport Support
- Offline-First Mobile App
- CDN & Static Asset Delivery
- Monitoring, Observability & Alerting
- Security & Compliance
- API Design
- Cost Estimation
- Testing Strategy
- Interview Q&A
1. Problem Overview & Scope
A live sports scoring platform delivers real-time scores, statistics, match events, and analytics to millions of concurrent users across web, mobile, and third-party integrations. Think ESPN, SofaScore, FlashScore, or FotMob — platforms that must update within 1-3 seconds of an actual on-field event, serve during peak events like the World Cup Final or Super Bowl, and handle everything from football to cricket to tennis under one roof.
The fundamental challenge is extreme read-heavy asymmetry combined with strict latency guarantees. A single goal event at the 89th minute of a Premier League match can trigger 50 million simultaneous notifications and WebSocket pushes within 2 seconds. The write path is tiny — one goal — but the fanout is enormous. Add multi-sport coverage (each with wildly different data models), betting data feeds that demand sub-second accuracy, fantasy sports point calculations that must be correct to the millisecond, and a global CDN infrastructure that delivers to every continent simultaneously.
Why This Problem Is Unique
- Temporal burstiness: Traffic follows the sports calendar, not business hours. A Saturday afternoon with 10 Premier League matches running simultaneously generates 10x normal traffic.
- Latency-sensitive but not real-time trading: 1-3 seconds is acceptable, but 30 seconds is useless. This is faster than social media but slower than financial markets.
- Data provider dependency: Scores come from external providers (Sportradar, Opta, Stats Perform) with their own APIs, latencies, and outage patterns.
- Multi-schema complexity: A football goal and a cricket wicket and a tennis ace are fundamentally different events with different metadata.
- Global audience, regional compliance: GDPR in Europe, data residency requirements, gambling regulations for betting features.
2. Functional & Non-Functional Requirements
Functional Requirements
| Feature | Description | Priority |
|---|---|---|
| Live Score Updates | Real-time score display for all active matches across all sports | P0 |
| Match Events Feed | Goals, cards, substitutions, fouls, set pieces — sport-specific event streams | P0 |
| Match Statistics | Possession, shots, passes, corners, and sport-specific aggregations | P0 |
| Push Notifications | Score alerts, goal notifications, match start/end alerts | P0 |
| Fixtures & Calendar | Browse upcoming matches, filter by league/sport/date | P0 |
| League Standings | Tables with points, goal difference, form, and tiebreakers | P1 |
| Player Profiles | Career stats, season stats, match-by-match breakdowns | P1 |
| Fantasy Sports | Points calculation, team management, leaderboards | P1 |
| Betting Odds | Live and pre-match odds from multiple bookmakers | P1 |
| Highlight Clips | Video clips for key moments (goals, red cards, etc.) | P2 |
| Social Feed | Match-specific social media integration and fan reactions | P2 |
| Multi-Sport | Football, basketball, cricket, tennis, rugby, baseball, and more | P0 |
Non-Functional Requirements
| Requirement | Target |
|---|---|
| End-to-end latency | < 3 seconds from on-field event to client screen |
| Concurrent users | 50M on a single match, 200M daily active |
| Availability | 99.99% during major events (8.76 min downtime/year) |
| Throughput | 1M WebSocket messages/second during peak |
| Data accuracy | 99.99% (wrong scores destroy trust) |
| Global latency | < 200ms API response for non-live data |
| Offline support | Mobile apps show cached data when offline |
3. High-Level Architecture
The system follows an event-driven architecture with a clear separation between the write path (data ingestion), the processing layer (enrichment, aggregation, fan-out), and the read path (serving to clients). Every component is independently scalable, and the entire system is designed for graceful degradation — if the betting feed goes down, scores still update.
Key Architectural Decisions
- Event sourcing for match state: The current score is derived from replaying events, not from overwrites. This provides a complete audit trail and enables historical replay.
- Kafka as the backbone: All inter-service communication flows through Kafka topics, providing durability, ordering guarantees per partition, and natural backpressure.
- Redis for hot match state: The current score, active match events, and statistics live in Redis with <1ms read latency. PostgreSQL is the durable source of truth.
- WebSocket fan-out at the edge: Clients connect to regional WebSocket gateways. Fan-out happens at the gateway level using Redis Pub/Sub for cross-region replication.
- Multi-provider ingestion: Data from multiple providers is merged with a priority system. If Sportradar sends a goal and Opta sends a goal within 5 seconds, we deduplicate. If they disagree, higher-priority provider wins.
4. Real-Time Score Ingestion Pipeline
The ingestion pipeline is the foundation of the entire system. Data arrives from external providers via their proprietary APIs — Sportradar's REST API pushes JSON events every 5-30 seconds per match, Opta provides an XML feed, and some smaller providers require periodic polling. The ingestion gateway normalizes, validates, deduplicates, and routes these events into Kafka.
Data Provider Integration
| Provider | Protocol | Latency | Coverage | Reliability |
|---|---|---|---|---|
| Sportradar | REST Push (Webhook) | 1-3s | All major sports | 99.95% |
| Opta (Stats Perform) | XML Socket Feed | 1-5s | Football, tennis | 99.9% |
| ESPN API | REST (polling) | 5-15s | US sports focus | 99.8% |
| Custom Scrapers | Various | 10-30s | Niche leagues | 90-95% |
Ingestion Gateway Implementation
C#
public class IngestionGateway : BackgroundService
{
private readonly IEnumerable<IDataProvider> _providers;
private readonly IKafkaProducer<string, RawMatchEvent> _producer;
private readonly IMatchEventValidator _validator;
private readonly IEventDeduplicator _deduplicator;
private readonly ILogger<IngestionGateway> _logger;
public IngestionGateway(
IEnumerable<IDataProvider> providers,
IKafkaProducer<string, RawMatchEvent> producer,
IMatchEventValidator validator,
IEventDeduplicator deduplicator,
ILogger<IngestionGateway> logger)
{
_providers = providers;
_producer = producer;
_validator = validator;
_deduplicator = deduplicator;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var tasks = _providers.Select(p => ConsumeProviderAsync(p, ct));
await Task.WhenAll(tasks);
}
private async Task ConsumeProviderAsync(
IDataProvider provider, CancellationToken ct)
{
await foreach (var rawEvent in provider.SubscribeAsync(ct))
{
var sw = Stopwatch.StartNew();
var normalized = provider.Normalize(rawEvent);
var validationResult = _validator.Validate(normalized);
if (!validationResult.IsValid)
{
_logger.LogWarning(
"Invalid event from {Provider}: {Errors}",
provider.Name, validationResult.Errors);
continue;
}
if (await _deduplicator.IsDuplicateAsync(normalized))
{
_logger.LogDebug(
"Duplicate event {EventId} from {Provider}",
normalized.EventId, provider.Name);
continue;
}
var kafkaMessage = new Message<string, RawMatchEvent>
{
Key = normalized.MatchId,
Value = normalized
};
await _producer.ProduceAsync(
"raw-match-events", kafkaMessage, ct);
sw.Stop();
_logger.LogInformation(
"Ingested {EventType} for match {MatchId} from {Provider} in {Elapsed}ms",
normalized.EventType, normalized.MatchId,
provider.Name, sw.ElapsedMilliseconds);
}
}
}
Data Provider Interface
C#
public interface IDataProvider
{
string Name { get; }
SportType Sport { get; }
int Priority { get; }
IAsyncEnumerable<RawEvent> SubscribeAsync(CancellationToken ct);
NormalizedMatchEvent Normalize(RawEvent raw);
}
public class SportradarProvider : IDataProvider
{
private readonly HttpClient _httpClient;
public string Name => "sportradar";
public SportType Sport => SportType.All;
public int Priority => 1;
public async IAsyncEnumerable<RawEvent> SubscribeAsync(
[EnumeratorCancellation] CancellationToken ct)
{
var request = new HttpRequestMessage(
HttpMethod.Get,
"https://api.sportradar.us/v1/events/stream");
request.Headers.Add("Authorization", $"Bearer {_apiKey}");
var response = await _httpClient.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, ct);
var stream = await response.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (string.IsNullOrEmpty(line)) continue;
if (line.StartsWith("data:"))
{
var json = line.Substring(5).Trim();
var evt = JsonSerializer.Deserialize<RawEvent>(json, _jsonOptions);
if (evt != null) yield return evt;
}
}
}
public NormalizedMatchEvent Normalize(RawEvent raw)
{
return new NormalizedMatchEvent
{
EventId = $"sr-{raw.Id}",
MatchId = raw.Match.Id,
Provider = Name,
Sport = SportType.Football,
EventType = MapEventType(raw.Type),
Timestamp = raw.Timestamp,
Minute = raw.Period?.Clock?.Elapsed,
TeamId = raw.Participant?.Id,
PlayerId = raw球员?.Id,
Metadata = JsonSerializer.SerializeToElement(raw)
};
}
}
Event Deduplication
When the same goal is reported by Sportradar and Opta within seconds of each other, we must emit only one canonical event. The deduplication uses a composite key of (match_id, event_type, approximate_minute, team_id) with a 10-second sliding window stored in Redis.
C#
public class RedisEventDeduplicator : IEventDeduplicator
{
private readonly IDatabase _redis;
private readonly TimeSpan _window = TimeSpan.FromSeconds(10);
public async Task<bool> IsDuplicateAsync(NormalizedMatchEvent evt)
{
var key = $"dedup:{evt.MatchId}:" +
$"{evt.EventType}:" +
$"{evt.TeamId}:" +
$"{evt.Minute ?? 0}";
var added = await _redis.StringSetAsync(
key, evt.EventId, _window, When.NotExists);
return !added;
}
}
5. Match Event Modeling
Every action on the field — a goal, a yellow card, a substitution, a corner kick — is modeled as an immutable event. Events are the atomic building blocks of the system. The current match state (score, cards, lineup) is always derived by replaying events from the beginning. This event-sourced approach provides a complete audit trail, enables historical analysis, and makes it trivial to replay a match for debugging.
Core Event Model
C#
public abstract class MatchEvent
{
public Guid EventId { get; init; }
public string MatchId { get; init; }
public string Provider { get; init; }
public DateTimeOffset Timestamp { get; init; }
public int? Minute { get; init; }
public int? AddedTime { get; init; }
public string Period { get; init; }
public abstract EventType EventType { get; }
}
public class GoalEvent : MatchEvent
{
public override EventType EventType => EventType.Goal;
public string ScorerId { get; init; }
public string ScorerName { get; init; }
public string TeamId { get; init; }
public GoalType GoalType { get; init; }
public string? AssistId { get; init; }
public string? AssistName { get; init; }
public string? Description { get; init; }
}
public class CardEvent : MatchEvent
{
public override EventType EventType =>
CardColor == CardColor.Red ? EventType.RedCard : EventType.YellowCard;
public string PlayerId { get; init; }
public string PlayerName { get; init; }
public string TeamId { get; init; }
public CardColor CardColor { get; init; }
public string? Reason { get; init; }
public bool IsSecondYellow { get; init; }
}
public class SubstitutionEvent : MatchEvent
{
public override EventType EventType => EventType.Substitution;
public string PlayerOutId { get; init; }
public string PlayerOutName { get; init; }
public string PlayerInId { get; init; }
public string PlayerInName { get; init; }
public string TeamId { get; init; }
public string? Reason { get; init; }
}
public class MatchState
{
public string MatchId { get; init; }
public int HomeScore { get; set; }
public int AwayScore { get; set; }
public MatchStatus Status { get; set; }
public List<MatchEvent> Events { get; } = new();
public Dictionary<string, int> TeamCards { get; } = new();
public List<SubstitutionEvent> Substitutions { get; } = new();
public DateTimeOffset LastUpdated { get; set; }
}
public enum EventType
{
Goal, YellowCard, RedCard, Substitution,
Penalty, Corner, FreeKick, ThrowIn,
Offside, VAR, Injury, WaterBreak,
MatchStart, HalfTime, FullTime, ExtraTimeStart,
PenaltyShootout, MatchSuspended, MatchAbandoned
}
Event Processing Pipeline
6. Sport-Specific Schemas
Each sport has fundamentally different scoring rules, event types, and statistics. A unified schema that tries to represent everything becomes an unwieldy God object. Instead, we use a base event model with sport-specific extensions.
Football (Soccer)
| Event | Data Fields | Aggregations |
|---|---|---|
| Goal | Scorer, assist, minute, goal type (open play/penalty/free kick/header/own goal) | Score, scorer stats, assist stats |
| Card | Player, color, reason, second yellow flag | Team discipline, suspended players |
| Substitution | Player in, player out, reason | Lineup changes, squad utilization |
| VAR | Decision (goal awarded/denied), original call, review time | VAR accuracy stats |
| Possession | Home %, Away %, per-half breakdown | Rolling 5-minute possession |
| Shots | On target, off target, blocked, by player | Shot map, xG (expected goals) |
| Passes | Complete, incomplete, by zone, key passes | Pass accuracy %, pass map |
Basketball (NBA)
| Event | Data Fields | Aggregations |
|---|---|---|
| Field Goal | Player, points (2/3), zone, assisted by, contested | FG%, 3P%, eFG% |
| Free Throw | Player, points, made/missed, fouled by | FT%, FTA |
| Rebound | Player, offensive/defensive | OREB, DREB, REB |
| Assist | Player, to player, points generated | AST, AST/TO ratio |
| Steal | Player, from player | STL, STL/TO |
| Block | Player, on player, shot type | BLK |
| Turnover | Player, type (bad pass, traveling, etc.) | TO |
| Foul | Player, type, team fouls per quarter | Team foul bonus tracking |
Cricket
| Event | Data Fields | Aggregations |
|---|---|---|
| Run | Runs (1-6), batsman, bowler, extras, balls since last wicket | Run rate, required rate |
| Wicket | Batsman out, bowler, fielder, dismissal type, score at dismissal | Wickets, bowling figures |
| Boundary | Batsman, runs (4 or 6), bowler, area of ground | Boundary count, scoring zones |
| Over | Bowler, runs conceded, wickets, extras | Economy rate, bowling analysis |
| Extras | Type (wide, no-ball, bye, leg-bye), runs | Total extras per innings |
| Partnership | Two batsman, runs scored, balls faced | Partnership breakdown |
Tennis
| Event | Data Fields | Aggregations |
|---|---|---|
| Point | Winner, serve direction, shot type, rally length | Aces, double faults, winners |
| Game | Winner, score, break of serve flag | Games, breaks |
| Set | Winner, score, tiebreak details | Sets won |
| Serve | Speed, placement, spin, first/second | Serve speed, 1st serve % |
| Break Point | Player, converted/ saved | BP conversion rate |
| Challenge | Player, line, outcome (successful/unsuccessful) | Challenge accuracy |
Unified Multi-Sport Event Store
C#
public class SportEventStore
{
private readonly IDbConnection _db;
public async Task<List<MatchEvent>> GetEventsAsync(
string matchId, SportType? sportFilter = null)
{
var sql = @"
SELECT event_id, match_id, sport, event_type,
event_data, minute, period, timestamp, provider
FROM match_events
WHERE match_id = @MatchId
AND (@Sport IS NULL OR sport = @Sport)
ORDER BY timestamp ASC";
var events = await _db.QueryAsync<StoredEvent>(
sql, new { MatchId = matchId, Sport = sportFilter });
return events.Select(e =>
DeserializeSportEvent(e.Sport, e)).ToList();
}
private MatchEvent DeserializeSportEvent(
SportType sport, StoredEvent stored)
{
return sport switch
{
SportType.Football =>
JsonSerializer.Deserialize<FootballEvent>(stored.EventData),
SportType.Basketball =>
JsonSerializer.Deserialize<BasketballEvent>(stored.EventData),
SportType.Cricket =>
JsonSerializer.Deserialize<CricketEvent>(stored.EventData),
SportType.Tennis =>
JsonSerializer.Deserialize<TennisEvent>(stored.EventData),
_ => throw new NotSupportedException($"Sport {sport} not supported")
};
}
}
7. Live Match Timeline
The match timeline is the most viewed UI component — a chronological feed of all events for a single match. It must update in real-time, support rich media (player photos, event icons), and handle late-arriving data (e.g., a VAR decision reversing a goal 2 minutes after it was initially awarded).
Timeline Architecture
Timeline Data Structure in Redis
We use Redis Streams for timeline storage, which provides ordered, append-only semantics with consumer groups for multiple readers.
C#
public class MatchTimelineService
{
private readonly IDatabase _redis;
private readonly IConnectionMultiplexer _mux;
private string StreamKey(string matchId) =>
$"timeline:{matchId}";
public async Task AddEventAsync(string matchId, MatchEvent evt)
{
var stream = _mux.GetStreamDatabase();
var fields = new NameValueEntry[]
{
new("event_id", evt.EventId.ToString()),
new("event_type", evt.EventType.ToString()),
new("minute", evt.Minute?.ToString() ?? ""),
new("team_id", evt.TeamId ?? ""),
new("player_id", evt球员Id ?? ""),
new("data", JsonSerializer.Serialize(evt)),
new("timestamp", evt.Timestamp
.ToUnixTimeMilliseconds().ToString())
};
await stream.AddAsync(StreamKey(matchId), "*", fields);
await _redis.PublishAsync(
Channel.Literal($"match:{matchId}:timeline"),
JsonSerializer.Serialize(new
{
type = "timeline_event",
matchId,
event = evt
}));
}
public async Task<List<TimelineEvent>> GetTimelineAsync(
string matchId, int? before = null, int count = 50)
{
var stream = _mux.GetStreamDatabase();
var startId = before?.ToString() ?? "0";
var entries = await stream.RangeAsync(
StreamKey(matchId), startId, "+",
count: count, reverse: true);
return entries.Select(MapToTimelineEvent).ToList();
}
}
8. Push Notifications & Score Alerts
Push notifications are the most latency-sensitive output of the system. A "GOAL!" notification arriving 10 seconds after the user's team scores feels broken. We target sub-2-second delivery from event ingestion to device notification. This requires a dedicated notification pipeline that runs in parallel with the WebSocket fan-out.
Notification Architecture
Notification Batching
During a 5-minute spell where one team scores 3 goals, we don't send 3 separate notifications. The batching service groups notifications by user and match, creating a single rich notification: "Arsenal 3-0 Manchester United Goals: Saka (12'), Havertz (34'), Saka (41')".
C#
public class NotificationBatcher
{
private readonly IDatabase _redis;
private readonly TimeSpan _batchWindow = TimeSpan.FromSeconds(5);
private readonly Dictionary<string, List<PendingNotification>>
_pending = new();
public async Task EnqueueNotificationAsync(
PendingNotification notification)
{
var key = $"{notification.UserId}:{notification.MatchId}";
lock (_pending)
{
if (!_pending.ContainsKey(key))
{
_pending[key] = new List<PendingNotification>();
_ = ScheduleFlushAsync(key);
}
_pending[key].Add(notification);
}
}
private async Task ScheduleFlushAsync(string key)
{
await Task.Delay(_batchWindow);
List<PendingNotification> batch;
lock (_pending)
{
if (!_pending.TryGetValue(key, out batch!)) return;
_pending.Remove(key);
}
var merged = MergeNotifications(batch);
await SendMergedNotificationAsync(merged);
}
private MergedNotification MergeNotifications(
List<PendingNotification> notifications)
{
var first = notifications.First();
var goals = notifications
.Where(n => n.Type == NotificationType.Goal).ToList();
if (goals.Count == 1)
return new MergedNotification
{
Title = "GOAL!",
Body = $"{goals[0].ScorerName} scores! {goals[0].MatchScore}",
Data = goals[0].ToDictionary()
};
var scorers = string.Join(", ",
goals.Select(g => g.ScorerName));
return new MergedNotification
{
Title = $"{goals.Count} Goals!",
Body = $"{scorers} - {goals.Last().MatchScore}",
Data = new Dictionary<string, string>
{
["matchId"] = first.MatchId,
["goalCount"] = goals.Count.ToString()
}
};
}
}
User Alert Preferences
| Alert Type | Default | Configurable | Batchable |
|---|---|---|---|
| Goal scored | On | Yes | Yes (5s window) |
| Red card | On | Yes | No (immediate) |
| Half-time | On | Yes | No |
| Full-time | On | Yes | No |
| Starting lineup | Off | Yes | No |
| Substitution | Off | Yes | No |
| Pre-match (kickoff in 1h) | On | Yes | No |
| Custom score threshold | N/A | Yes | No |
9. WebSocket Streaming to Clients
WebSockets are the primary real-time transport. At peak (World Cup Final), we maintain 50 million concurrent WebSocket connections. This requires a horizontally scaled WebSocket gateway fleet, efficient connection routing, and intelligent fan-out that avoids duplicating messages.
WebSocket Gateway Architecture
WebSocket Gateway Implementation
C#
public class WebSocketGateway
{
private readonly ConnectionPool _connections;
private readonly ISubscriber _redisSub;
public async Task HandleConnectionAsync(
WebSocket socket, HttpContext context)
{
var userId = context.User.FindFirst("sub")?.Value;
var connId = Guid.NewGuid().ToString();
var connection = new ClientConnection
{
Id = connId,
UserId = userId,
Socket = socket,
Subscriptions = new HashSet<string>(),
ConnectedAt = DateTimeOffset.UtcNow
};
_connections.Add(connection);
try
{
await _redisSub.SubscribeAsync(
Channel.Literal("ws:fan-out"),
async (channel, message) =>
{
var evt = JsonSerializer
.Deserialize<FanOutMessage>(message.ToString());
if (connection.Subscriptions.Contains(evt.Channel))
{
var payload = JsonSerializer.Serialize(evt.Data);
var bytes = Encoding.UTF8.GetBytes(payload);
await socket.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text, true,
CancellationToken.None);
}
});
var buffer = new byte[4096];
while (socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(
new ArraySegment<byte>(buffer),
CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"", CancellationToken.None);
}
else if (result.MessageType == WebSocketMessageType.Text)
{
var msg = JsonSerializer.Deserialize<ClientMessage>(
Encoding.UTF8.GetString(buffer, 0, result.Count));
await HandleClientMessageAsync(connection, msg);
}
}
}
finally
{
_connections.Remove(connId);
}
}
private async Task HandleClientMessageAsync(
ClientConnection conn, ClientMessage msg)
{
switch (msg.Type)
{
case "subscribe":
conn.Subscriptions.Add(msg.Channel);
var snapshot = await GetSnapshotAsync(msg.Channel);
if (snapshot != null) await SendMessageAsync(conn, snapshot);
break;
case "unsubscribe":
conn.Subscriptions.Remove(msg.Channel);
break;
case "ping":
await SendMessageAsync(conn,
new { type = "pong", ts = DateTimeOffset.UtcNow });
break;
}
}
}
Fan-Out Strategy
| Approach | Pros | Cons | When to Use |
|---|---|---|---|
| Redis Pub/Sub | Sub-millisecond latency, simple | No persistence, no backpressure | Real-time push to connected clients |
| Kafka Consumer Groups | Durability, ordering, replay | Higher latency (10-50ms) | Cross-region replication, offline catch-up |
| Hybrid (Kafka to Redis) | Best of both | Added complexity | Production recommendation |
Connection Efficiency: Channel Multiplexing
Instead of creating a separate connection per match per user, we multiplex all subscriptions over a single WebSocket connection. The client subscribes to channels like match:abc123:events, match:abc123:stats, league:premier-league:standings. The gateway maps channels to a single Redis Pub/Sub topic and filters messages per connection.
10. Match Statistics Aggregation
Match statistics — possession percentage, shots on target, pass accuracy, corners, fouls — are computed in real-time as events flow through the system. The aggregation service maintains running counters in Redis and periodically flushes snapshots to PostgreSQL for historical storage.
Football Statistics Model
C#
public class MatchStatistics
{
public string MatchId { get; set; }
public int HomePossessionPercent { get; set; }
public int AwayPossessionPercent { get; set; }
public List<PossessionPeriod> PossessionByPeriod { get; set; }
public int HomeShotsOnTarget { get; set; }
public int HomeShotsOffTarget { get; set; }
public int AwayShotsOnTarget { get; set; }
public int AwayShotsOffTarget { get; set; }
public List<ShotEvent> ShotMap { get; set; }
public int HomePassesCompleted { get; set; }
public int HomePassesAttempted { get; set; }
public int AwayPassesCompleted { get; set; }
public int AwayPassesAttempted { get; set; }
public int HomeCorners { get; set; }
public int AwayCorners { get; set; }
public int HomeFouls { get; set; }
public int AwayFouls { get; set; }
public int HomeOffsides { get; set; }
public int AwayOffsides { get; set; }
public int HomeYellowCards { get; set; }
public int AwayYellowCards { get; set; }
public int HomeRedCards { get; set; }
public int AwayRedCards { get; set; }
public decimal HomeXG { get; set; }
public decimal AwayXG { get; set; }
public int HomeDangerousAttacks { get; set; }
public int AwayDangerousAttacks { get; set; }
public DateTimeOffset LastUpdated { get; set; }
}
public class RealTimeStatsAggregator
{
private readonly IDatabase _redis;
public async Task UpdateStatsAsync(
string matchId, MatchEvent evt)
{
var key = $"stats:{matchId}";
switch (evt)
{
case GoalEvent goal:
var field = goal.TeamId == await GetHomeTeamAsync(matchId)
? "home_goals" : "away_goals";
await _redis.HashIncrementAsync(key, field);
break;
case ShotEvent shot:
await _redis.HashIncrementAsync(key,
$"_{shot.Side}_shots_{shot.OnTarget ? "on" : "off"}_target");
break;
case PassEvent pass:
await _redis.HashIncrementAsync(key,
$"_{pass.Side}_passes_attempted");
if (pass.Completed)
await _redis.HashIncrementAsync(key,
$"_{pass.Side}_passes_completed");
break;
case CardEvent card:
await _redis.HashIncrementAsync(key,
$"_{card.Side}_{card.CardColor.ToString().ToLower()}_cards");
break;
case CornerEvent:
await _redis.HashIncrementAsync(key,
$"_{evt.Side}_corners");
break;
case FoulEvent:
await _redis.HashIncrementAsync(key,
$"_{evt.Side}_fouls");
break;
}
await RecalculatePossessionAsync(matchId);
await PublishStatsUpdateAsync(matchId);
}
private async Task RecalculatePossessionAsync(string matchId)
{
var key = $"stats:{matchId}";
var home = int.Parse(await _redis.HashGetAsync(key,
"home_possession_events"));
var away = int.Parse(await _redis.HashGetAsync(key,
"away_possession_events"));
var total = home + away;
if (total > 0)
{
var homePct = (int)Math.Round((double)home / total * 100);
await _redis.HashSetAsync(key, new[] {
new HashEntry("home_possession_pct", homePct),
new HashEntry("away_possession_pct", 100 - homePct)
});
}
}
}
xG (Expected Goals) Calculation
Expected Goals is the most important advanced metric in modern football analytics. It estimates the probability that a given shot results in a goal based on: shot location (distance and angle from goal), body part (foot, head), shot type (open play, free kick, penalty), and pre-shot events (through ball, cross, dribble). We use a pre-trained logistic regression model served as an ONNX model in the aggregation service.
C#
public class XGCalculator
{
private readonly InferenceSession _model;
public decimal CalculateXG(ShotEvent shot)
{
var features = new float[]
{
shot.DistanceFromGoal,
shot.AngleFromGoal,
shot.IsOneOnOne ? 1f : 0f,
shot.IsHeader ? 1f : 0f,
shot.IsVolley ? 1f : 0f,
shot.IsFreeKick ? 1f : 0f,
shot.IsPenalty ? 1f : 0f,
shot.BodyPart == BodyPart.RightFoot ? 1f : 0f,
shot.BodyPart == BodyPart.LeftFoot ? 1f : 0f,
shot.BodyPart == BodyPart.Head ? 1f : 0f,
shot.FirstTime ? 1f : 0f,
shot.IsBigChance ? 1f : 0f,
};
var input = new NamedOnnxValue[]
{
NamedOnnxValue.CreateFromTensor(
"features",
new DenseTensor<float>(features,
new[] { 1, features.Length }))
};
var result = _model.Run(input);
var probability = result.First().AsEnumerable<float>().First();
return Math.Round((decimal)probability, 3);
}
}
11. Historical Data & Analytics
Every match event, statistic, and state snapshot is persisted for historical analysis. This data powers head-to-head records, season statistics, historical trends, and data journalism. The storage strategy uses a tiered approach: hot data (current season) in PostgreSQL, warm data (1-3 years) in partitioned tables, and cold data (3+ years) compressed in S3 with Athena for ad-hoc queries.
Data Retention Strategy
| Data Tier | Age | Storage | Access Pattern | Retention |
|---|---|---|---|---|
| Hot | Current season | PostgreSQL (SSD) | Real-time queries | Indefinite |
| Warm | 1-3 years | PostgreSQL (HDD partitioned) | Historical lookups | Indefinite |
| Cold | 3+ years | S3 Parquet + Athena | Analytics, data journalism | Indefinite |
| Archive | 7+ years | S3 Glacier | Compliance only | 10 years |
PostgreSQL Partitioning
SQL
CREATE TABLE match_events (
event_id UUID PRIMARY KEY,
match_id VARCHAR(50) NOT NULL,
sport VARCHAR(20) NOT NULL,
event_type VARCHAR(30) NOT NULL,
minute INTEGER,
period VARCHAR(20),
event_data JSONB NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
provider VARCHAR(30) NOT NULL
) PARTITION BY RANGE (timestamp);
CREATE TABLE match_events_2025_26
PARTITION OF match_events
FOR VALUES FROM ('2025-08-01') TO ('2026-07-01');
CREATE TABLE match_events_2024_25
PARTITION OF match_events
FOR VALUES FROM ('2024-08-01') TO ('2025-08-01');
CREATE INDEX idx_events_match
ON match_events (match_id, timestamp);
CREATE INDEX idx_events_type
ON match_events (event_type, sport, timestamp);
CREATE INDEX idx_events_player
ON match_events USING GIN (
event_data ->> 'player_id'
);
Analytics Data Warehouse
For complex analytical queries (e.g., "which players have the highest xG per 90 minutes in the top 5 leagues this season"), we replicate event data to a columnar data warehouse using Apache Parquet files in S3. Amazon Athena or Snowflake provides SQL access over these files for analysts and data journalists.
C#
public class AnalyticsExporter : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var matches = await GetCompletedMatchesAsync(
DateTimeOffset.UtcNow.AddDays(-1));
foreach (var match in matches)
{
var events = await GetMatchEventsAsync(match.MatchId);
var parquetBytes = ConvertToParquet(events);
var key = $"analytics/events/" +
$"{match.Sport}/" +
$"{match.Date:yyyy/MM/dd}/" +
$"{match.MatchId}.parquet";
await _s3.PutObjectAsync(new PutObjectRequest
{
BucketName = "sports-analytics-data",
Key = key,
InputStream = new MemoryStream(parquetBytes)
});
}
await Task.Delay(TimeSpan.FromHours(1), ct);
}
}
}
12. Fixture Scheduling & Calendar
Fixtures are the backbone of the platform — every match, kickoff time, venue, and broadcast information must be accurately maintained for all sports and leagues worldwide. Fixture data comes from a combination of data providers and official league APIs, with automated detection of schedule changes (postponements, kickoff time changes, venue changes).
Fixture Data Model
C#
public class Fixture
{
public string FixtureId { get; init; }
public string ExternalId { get; init; }
public SportType Sport { get; init; }
public string LeagueId { get; init; }
public string LeagueName { get; init; }
public string Season { get; init; }
public int Matchday { get; init; }
public FixtureTeam Home { get; init; }
public FixtureTeam Away { get; init; }
public DateTimeOffset KickoffTime { get; init; }
public string? Venue { get; init; }
public string? City { get; init; }
public string? Country { get; init; }
public FixtureStatus Status { get; set; }
public int? HomeScore { get; set; }
public int? AwayScore { get; set; }
public List<BroadcastInfo> Broadcasts { get; init; }
public List<FixtureChange> ChangeHistory { get; } = new();
public DateTimeOffset LastUpdated { get; set; }
}
public class FixtureTeam
{
public string TeamId { get; init; }
public string Name { get; init; }
public string ShortName { get; init; }
public string CrestUrl { get; init; }
}
public enum FixtureStatus
{
Scheduled, InPlay, HalfTime, FullTime,
ExtraTime, PenaltyShootout, Postponed,
Cancelled, Abandoned, Delayed, Result
}
Fixture Change Detection
Leagues frequently change kickoff times — sometimes weeks before a match, sometimes hours before. The fixture sync service polls providers every 15 minutes and compares with stored fixtures. Any changes are detected, validated, and propagated to all dependent systems (notifications, cache invalidation, calendar sync).
C#
public class FixtureSyncService
{
public async Task SyncFixturesAsync(
string leagueId, SportType sport)
{
var remoteFixtures = await _provider
.GetFixturesAsync(leagueId);
var localFixtures = await _fixtureRepo
.GetByLeagueAsync(leagueId);
var localDict = localFixtures
.ToDictionary(f => f.ExternalId);
foreach (var remote in remoteFixtures)
{
if (localDict.TryGetValue(
remote.ExternalId, out var local))
{
var changes = DetectChanges(local, remote);
if (changes.Any())
{
foreach (var change in changes)
{
local.ChangeHistory.Add(new FixtureChange
{
Field = change.Field,
OldValue = change.OldValue,
NewValue = change.NewValue,
ChangedAt = DateTimeOffset.UtcNow
});
}
ApplyChanges(local, remote);
await _fixtureRepo.UpdateAsync(local);
await _mediator.Publish(
new FixtureChanged(local, changes));
}
}
else
{
var newFixture = MapToFixture(remote);
await _fixtureRepo.InsertAsync(newFixture);
await _mediator.Publish(
new FixtureCreated(newFixture));
}
}
}
}
Calendar Integration
Users can export fixtures to their personal calendars (Google Calendar, Apple Calendar, Outlook). We generate iCalendar (.ics) files with automatic updates when kickoff times change.
13. League Standings & Tables
League tables are one of the most viewed pages on any sports platform. They must be calculated correctly according to league-specific rules (different leagues have different tiebreaker rules), update immediately after a match result, and handle complex scenarios like points deductions (e.g., Everton in the Premier League).
League Table Calculation
C#
public class LeagueTableCalculator
{
private readonly ILeagueRepository _leagueRepo;
public async Task<LeagueTable> CalculateAsync(
string leagueId, string season)
{
var matches = await _leagueRepo
.GetCompletedMatchesAsync(leagueId, season);
var teams = await _leagueRepo
.GetTeamsAsync(leagueId, season);
var adjustments = await _leagueRepo
.GetPointAdjustmentsAsync(leagueId, season);
var standings = teams.Select(t => new TeamStanding
{
TeamId = t.TeamId,
TeamName = t.Name,
CrestUrl = t.CrestUrl
}).ToDictionary(t => t.TeamId);
foreach (var match in matches)
{
var home = standings[match.HomeTeamId];
var away = standings[match.AwayTeamId];
home.Played++;
away.Played++;
home.GoalsFor += match.HomeScore;
home.GoalsAgainst += match.AwayScore;
away.GoalsFor += match.AwayScore;
away.GoalsAgainst += match.HomeScore;
if (match.HomeScore > match.AwayScore)
{
home.Won++;
home.Points += 3;
away.Lost++;
}
else if (match.HomeScore < match.AwayScore)
{
away.Won++;
away.Points += 3;
home.Lost++;
}
else
{
home.Drawn++;
away.Drawn++;
home.Points++;
away.Points++;
}
UpdateForm(home, match, isHome: true);
UpdateForm(away, match, isHome: false);
}
foreach (var adj in adjustments)
{
if (standings.ContainsKey(adj.TeamId))
standings[adj.TeamId].Points += adj.Points;
}
var sorted = standings.Values
.OrderByDescending(s => s.Points)
.ThenByDescending(s => s.GoalDifference)
.ThenByDescending(s => s.GoalsFor)
.ThenByDescending(s => s.HeadToHeadPoints)
.ThenByDescending(s => s.HeadToHeadGoalDiff)
.ToList();
for (int i = 0; i < sorted.Count; i++)
sorted[i].Position = i + 1;
return new LeagueTable
{
LeagueId = leagueId,
Season = season,
Standings = sorted,
LastUpdated = DateTimeOffset.UtcNow
};
}
}
Table Display Features
| Column | Description | Tooltip |
|---|---|---|
| Pos | League position | Position movement arrows |
| Team | Team name + crest | Link to team page |
| P | Matches played | - |
| W | Wins | - |
| D | Draws | - |
| L | Losses | - |
| GF | Goals for | - |
| GA | Goals against | - |
| GD | Goal difference | Color coded |
| Pts | Points | Bold for leaders |
| Form | Last 5 results (W/D/L) | Green/gray/red dots |
14. Player Profiles & Season Stats
Player profiles aggregate career statistics, season-by-season breakdowns, match-by-match performance, and biographical data. They serve both the detailed stat pages and the fantasy sports feature (which needs current season stats for point calculations).
Player Data Model
C#
public class PlayerProfile
{
public string PlayerId { get; init; }
public string FullName { get; init; }
public string ShortName { get; init; }
public string Nationality { get; init; }
public DateTime DateOfBirth { get; init; }
public string Position { get; init; }
public decimal? Height { get; init; }
public decimal? Weight { get; init; }
public string PreferredFoot { get; init; }
public string PhotoUrl { get; init; }
public List<ClubHistory> CareerHistory { get; init; }
public PlayerSeasonStats CurrentSeason { get; set; }
public List<PlayerSeasonStats> SeasonHistory { get; init; }
public PlayerMarketValue? MarketValue { get; init; }
}
public class PlayerSeasonStats
{
public string Season { get; init; }
public string TeamId { get; init; }
public string LeagueId { get; init; }
public int Appearances { get; set; }
public int Starts { get; set; }
public int MinutesPlayed { get; set; }
public int Goals { get; set; }
public int Assists { get; set; }
public decimal XG { get; set; }
public decimal XA { get; set; }
public int Shots { get; set; }
public int ShotsOnTarget { get; set; }
public int KeyPasses { get; set; }
public int PassesCompleted { get; set; }
public int PassesAttempted { get; set; }
public decimal PassAccuracy { get; set; }
public int TacklesWon { get; set; }
public int Interceptions { get; set; }
public int Blocks { get; set; }
public int Clearances { get; set; }
public int AerialDuelsWon { get; set; }
public int AerialDuelsLost { get; set; }
public int FoulsCommitted { get; set; }
public int FoulsSuffered { get; set; }
public int YellowCards { get; set; }
public int RedCards { get; set; }
public int CleanSheets { get; set; }
public int Saves { get; set; }
public int PenaltiesSaved { get; set; }
public decimal SavePercentage { get; set; }
public int GoalsConceded { get; set; }
}
public class PlayerStatsCalculator
{
private readonly IPlayerRepository _repo;
public async Task<PlayerSeasonStats> CalculateSeasonStatsAsync(
string playerId, string season)
{
var appearances = await _repo
.GetPlayerAppearancesAsync(playerId, season);
var stats = new PlayerSeasonStats
{
Season = season,
Appearances = appearances.Count,
Starts = appearances.Count(a => a.IsStarter),
MinutesPlayed = appearances.Sum(a => a.MinutesPlayed)
};
foreach (var appearance in appearances)
{
var matchEvents = await _repo
.GetPlayerEventsAsync(playerId, appearance.MatchId);
foreach (var evt in matchEvents)
{
switch (evt)
{
case GoalEvent:
stats.Goals++;
break;
case AssistEvent:
stats.Assists++;
break;
case ShotEvent shot:
stats.Shots++;
if (shot.OnTarget) stats.ShotsOnTarget++;
stats.XG += shot.XGValue;
break;
}
}
}
stats.PassAccuracy = stats.PassesAttempted > 0
? Math.Round(
(decimal)stats.PassesCompleted /
stats.PassesAttempted * 100, 1)
: 0;
return stats;
}
}
15. Fantasy Sports Integration
Fantasy sports is a major engagement driver — users who manage fantasy teams check scores far more frequently. The platform must calculate fantasy points in real-time as match events occur, update leaderboards instantly, and support transfers, captain picks, and league management.
Fantasy Points Engine
C#
public class FantasyPointsEngine
{
private readonly IFantasyRepository _repo;
private readonly IMatchEventBus _eventBus;
private static readonly Dictionary<string, decimal>
FootballPoints = new()
{
["goal"] = 6m,
["assist"] = 3m,
["clean_sheet"] = 4m,
["goals_conceded"] = -1m,
["yellow_card"] = -1m,
["red_card"] = -3m,
["own_goal"] = -2m,
["penalty_saved"] = 5m,
["penalty_missed"] = -2m,
["save_per_3"] = 1m,
["bonus_top_performer"] = 1m,
};
public async Task<decimal> CalculatePointsAsync(
string playerId, string matchId)
{
var events = await _repo
.GetPlayerMatchEventsAsync(playerId, matchId);
var profile = await _repo
.GetPlayerProfileAsync(playerId);
decimal points = 0;
if (events.Any()) points += 2m;
var minutes = events.MaxOrDefault(e => e.Minute);
if (minutes >= 60) points += 1m;
foreach (var evt in events)
{
if (FootballPoints.TryGetValue(
evt.EventType.ToString().ToLower(),
out var eventPoints))
{
points += eventPoints;
}
if (profile.Position == "DEF" && evt is GoalEvent)
points += 1m;
if (profile.Position == "GK" && evt is SaveEvent save && save.IsPenaltySave)
points += FootballPoints["penalty_saved"];
}
var fantasyEntry = await _repo
.GetFantasyEntryForPlayerAsync(playerId, matchId);
if (fantasyEntry?.IsCaptain == true) points *= 2;
return points;
}
public async Task ProcessMatchEventAsync(
string matchId, MatchEvent evt)
{
var involvedPlayers = GetInvolvedPlayers(evt);
foreach (var playerId in involvedPlayers)
{
var points = await CalculatePointsAsync(playerId, matchId);
await _repo.UpsertFantasyPointsAsync(
playerId, matchId, points);
await UpdateLeagueStandingsAsync(playerId, points);
await _eventBus.PublishAsync(new FantasyPointsUpdated
{
PlayerId = playerId,
MatchId = matchId,
Points = points
});
}
}
}
Fantasy Points Table
| Action | FWD | MID | DEF | GKP |
|---|---|---|---|---|
| Goal | 4 | 5 | 6 | 6 |
| Assist | 3 | 3 | 3 | 3 |
| Clean Sheet | - | 1 | 4 | 4 |
| Goals Conceded | - | - | -1 | -1 |
| Yellow Card | -1 | -1 | -1 | -1 |
| Red Card | -3 | -3 | -3 | -3 |
| Penalty Saved | - | - | - | 5 |
| Penalty Missed | -2 | -2 | -2 | -2 |
16. Odds & Betting Data Feed
Betting odds are a critical feature that drives significant revenue through affiliate partnerships. The platform aggregates odds from multiple bookmakers, displays them alongside match data, and tracks line movements. This requires sub-second data freshness and strict compliance with gambling regulations.
Odds Data Model
C#
public class MatchOdds
{
public string MatchId { get; init; }
public DateTimeOffset LastUpdated { get; init; }
public Dictionary<string, BookmakerOdds> Bookmakers { get; init; }
public MarketOdds BestOdds { get; init; }
public OddsMovementSummary Movement { get; init; }
}
public class BookmakerOdds
{
public string BookmakerId { get; init; }
public string BookmakerName { get; init; }
public string LogoUrl { get; init; }
public AffiliateLink AffiliateUrl { get; init; }
public decimal HomeWin { get; set; }
public decimal Draw { get; set; }
public decimal AwayWin { get; set; }
public Dictionary<decimal, OverUnderOdds> OverUnder { get; set; }
public decimal BtsYes { get; set; }
public decimal BtsNo { get; set; }
public Dictionary<string, decimal> CorrectScore { get; set; }
public Dictionary<decimal, HandicapOdds> Handicap { get; set; }
public List<PlayerPropOdds> PlayerProps { get; set; }
public DateTimeOffset SnapshotTime { get; init; }
}
public class OddsMovementTracker
{
private readonly IDatabase _redis;
public async Task TrackMovementAsync(
string matchId, string bookmakerId, MarketOdds newOdds)
{
var key = $"odds_history:{matchId}:{bookmakerId}";
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var entries = new SortedSetEntry[]
{
new(JsonSerializer.Serialize(newOdds), timestamp)
};
await _redis.SortedSetAddAsync(key, entries);
await _redis.SortedSetRemoveRangeByRankAsync(key, 0, -1001);
var previous = await GetPreviousOddsAsync(matchId, bookmakerId);
if (previous != null && DetectSignificantMovement(previous, newOdds))
{
await PublishOddsAlertAsync(matchId, new OddsMovement
{
Bookmaker = bookmakerId,
Market = "home_win",
OldOdds = previous.HomeWin,
NewOdds = newOdds.HomeWin,
Direction = newOdds.HomeWin > previous.HomeWin ? "drift" : "shorten"
});
}
}
}
17. Highlight Clips & Video Integration
Highlight clips — goals, red cards, key saves — are the most shared content type on the platform. They must be available within seconds of the event, work across all devices, and be deliverable to millions of simultaneous viewers. We use a server-side rendering pipeline that transcodes clips on-the-fly and caches them on a global CDN.
Video Pipeline Architecture
Clip Generation Service
C#
public class HighlightClipGenerator
{
private readonly IVideoProcessor _ffmpeg;
private readonly ICDNUploader _cdn;
private readonly IThumbnailGenerator _thumbnails;
public async Task<GeneratedClip> GenerateClipAsync(
MatchEvent evt, string videoFeedUrl)
{
var window = evt switch
{
GoalEvent => new ClipWindow
{ PreSeconds = 15, PostSeconds = 10 },
RedCard => new ClipWindow
{ PreSeconds = 10, PostSeconds = 5 },
PenaltyEvent => new ClipWindow
{ PreSeconds = 8, PostSeconds = 8 },
SaveEvent => new ClipWindow
{ PreSeconds = 5, PostSeconds = 5 },
_ => new ClipWindow
{ PreSeconds = 5, PostSeconds = 5 }
};
var eventTime = evt.Timestamp;
var startTime = eventTime.AddSeconds(-window.PreSeconds);
var endTime = eventTime.AddSeconds(window.PostSeconds);
var clipPath = await _ffmpeg.ExtractClipAsync(
videoFeedUrl, startTime, endTime,
new TranscodeOptions
{
Formats = new[]
{
new VideoFormat { Width = 1920, Height = 1080, Bitrate = "5M" },
new VideoFormat { Width = 1280, Height = 720, Bitrate = "3M" },
new VideoFormat { Width = 854, Height = 480, Bitrate = "1.5M" },
new VideoFormat { Width = 640, Height = 360, Bitrate = "800k" },
},
AudioBitrate = "128k",
Format = "mp4",
Codec = "h264"
});
var thumbnail = await _thumbnails.GenerateAsync(
clipPath, eventTime - startTime);
var clipId = $"{evt.MatchId}_{evt.EventType}_{evt.Minute}";
var clipUrl = await _cdn.UploadAsync(
$"highlights/{clipId}/playlist.m3u8", clipPath);
var thumbUrl = await _cdn.UploadAsync(
$"highlights/{clipId}/thumbnail.jpg", thumbnail);
return new GeneratedClip
{
ClipId = clipId,
MatchId = evt.MatchId,
EventType = evt.EventType,
DurationSeconds = window.PreSeconds + window.PostSeconds,
Url = clipUrl,
ThumbnailUrl = thumbUrl,
Resolutions = new[] { "1080p", "720p", "480p", "360p" }
};
}
}
19. Multi-Sport Support
Supporting 50+ sports requires a plugin-based architecture where each sport is implemented as an isolated module. The core platform handles common concerns (authentication, caching, WebSocket delivery), while sport modules handle sport-specific logic (event types, statistics, scoring rules).
Sport Module Architecture
Sport Module Interface
C#
public interface ISportModule
{
SportType Sport { get; }
string Name { get; }
Type EventType { get; }
Type StatsType { get; }
Type MatchStateType { get; }
MatchEvent ProcessRawEvent(RawMatchEvent raw);
object CalculateStats(MatchState state);
object CalculateStandings(List<MatchResult> results);
ValidationResult ValidateEvent(MatchEvent evt);
bool IsMatchComplete(MatchState state);
EventType MapProviderEventType(string providerType);
}
public class FootballModule : ISportModule
{
public SportType Sport => SportType.Football;
public string Name => "Football (Soccer)";
public Type EventType => typeof(FootballEvent);
public Type StatsType => typeof(FootballStats);
public Type MatchStateType => typeof(FootballMatchState);
public MatchEvent ProcessRawEvent(RawMatchEvent raw)
{
return raw.EventType switch
{
"goal" => new GoalEvent
{
EventId = raw.Id,
MatchId = raw.MatchId,
ScorerId = raw球员?.Id,
TeamId = raw.Team?.Id,
GoalType = MapGoalType(raw.Attributes),
Minute = raw.Clock?.Elapsed
},
"yellow_card" => new CardEvent
{
EventId = raw.Id,
MatchId = raw.MatchId,
PlayerId = raw球员?.Id,
TeamId = raw.Team?.Id,
CardColor = CardColor.Yellow
},
};
}
public bool IsMatchComplete(MatchState state)
{
var football = (FootballMatchState)state;
return football.Status == MatchStatus.FullTime ||
football.Status == MatchStatus.PenaltyShootout;
}
}
public class SportModuleRegistry
{
private readonly Dictionary<SportType, ISportModule> _modules;
public SportModuleRegistry(IEnumerable<ISportModule> modules)
{
_modules = modules.ToDictionary(m => m.Sport);
}
public ISportModule GetModule(SportType sport)
{
if (!_modules.TryGetValue(sport, out var module))
throw new NotSupportedException($"Sport {sport} is not supported");
return module;
}
}
Supported Sports
| Sport | Priority | Events/Match | Stats Complexity | Provider |
|---|---|---|---|---|
| Football (Soccer) | P0 | 50-200 | High (xG, heat maps) | Sportradar, Opta |
| Basketball (NBA) | P0 | 200-400 | High (play-by-play) | Sportradar, NBA API |
| Cricket | P0 | 300-600 | Very High (ball-by-ball) | Sportradar, ESPNcricinfo |
| Tennis | P0 | 100-300 | High (point-by-point) | Sportradar, WTA/ATP |
| American Football (NFL) | P1 | 150-200 | High (play-by-play) | NFL API, Sportradar |
| Baseball (MLB) | P1 | 250-350 | Very High (Sabermetrics) | MLB API, Sportradar |
| Rugby | P1 | 50-150 | Medium | Sportradar |
| Ice Hockey (NHL) | P1 | 150-250 | High (Corsi, Fenwick) | NHL API, Sportradar |
| MMA/UFC | P2 | 10-30 | Low | UFC API |
| F1 Racing | P2 | 50-100 | High (telemetry) | F1 API |
20. Offline-First Mobile App
Mobile users often have spotty connectivity — on the subway, in a stadium with poor signal, or on a rural train. The mobile app must show cached data immediately on launch, sync in the background, and gracefully handle offline scenarios while clearly indicating data freshness.
Offline-First Architecture
Local Storage Schema (SQLite)
C#
public class LocalDatabase
{
public async Task InitializeAsync()
{
await _db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS matches (
match_id TEXT PRIMARY KEY,
sport TEXT NOT NULL,
league_id TEXT NOT NULL,
home_team TEXT NOT NULL,
away_team TEXT NOT NULL,
home_score INTEGER DEFAULT 0,
away_score INTEGER DEFAULT 0,
status TEXT NOT NULL,
kickoff_time TEXT NOT NULL,
venue TEXT,
last_updated TEXT NOT NULL,
is_live INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS match_events (
event_id TEXT PRIMARY KEY,
match_id TEXT NOT NULL,
event_type TEXT NOT NULL,
minute INTEGER,
player_name TEXT,
team_id TEXT,
event_data TEXT NOT NULL,
timestamp TEXT NOT NULL,
FOREIGN KEY (match_id) REFERENCES matches(match_id)
);
CREATE TABLE IF NOT EXISTS fixtures (
fixture_id TEXT PRIMARY KEY,
league_id TEXT NOT NULL,
matchday INTEGER,
home_team TEXT NOT NULL,
away_team TEXT NOT NULL,
kickoff_time TEXT NOT NULL,
venue TEXT,
last_updated TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS standings (
team_id TEXT PRIMARY KEY,
league_id TEXT NOT NULL,
position INTEGER,
team_name TEXT NOT NULL,
points INTEGER DEFAULT 0,
played INTEGER DEFAULT 0,
won INTEGER DEFAULT 0,
drawn INTEGER DEFAULT 0,
lost INTEGER DEFAULT 0,
goals_for INTEGER DEFAULT 0,
goals_against INTEGER DEFAULT 0,
form TEXT,
last_updated TEXT NOT NULL
);
");
}
}
public class OfflineSyncManager
{
private readonly LocalDatabase _local;
private readonly HttpClient _api;
private readonly IConnectivity _connectivity;
public async Task SyncOnConnectAsync()
{
if (!_connectivity.IsConnected) return;
await FlushOutboxAsync();
var liveMatches = await _local.GetLiveMatchesAsync();
foreach (var match in liveMatches)
{
var events = await _api.GetFromJsonAsync<
List<MatchEventDto>>(
$"/api/matches/{match.MatchId}/events?since={match.LastUpdated:O}");
if (events?.Any() == true)
{
await _local.UpsertMatchEventsAsync(
match.MatchId, events);
await _local.UpdateMatchScoreAsync(
match.MatchId,
events.Last().HomeScore,
events.Last().AwayScore);
}
}
if (await ShouldSyncFixturesAsync())
{
var fixtures = await _api
.GetFromJsonAsync<List<FixtureDto>>(
"/api/fixtures/upcoming?limit=100");
if (fixtures != null)
await _local.SyncFixturesAsync(fixtures);
}
if (await ShouldSyncStandingsAsync())
{
var standings = await _api
.GetFromJsonAsync<List<StandingDto>>(
"/api/standings/top-leagues");
if (standings != null)
await _local.SyncStandingsAsync(standings);
}
}
}
Offline Indicators
| Scenario | UI Indicator | Behavior |
|---|---|---|
| Full connectivity | Live dot (green) | Real-time WebSocket updates |
| Intermittent connectivity | Yellow dot, "Updates may be delayed" | Periodic polling, retry on failure |
| Fully offline | Red dot, "Offline - showing cached data" | Local cache only, timestamp shown |
| Stale data (>5min) | "Updated X minutes ago" badge | Background sync when available |
21. CDN & Static Asset Delivery
Static assets — team crests, player photos, league logos, app icons, highlight clip thumbnails — account for 60%+ of bandwidth. A global CDN reduces origin load by 95%+ and ensures sub-100ms load times worldwide.
CDN Strategy
| Asset Type | TTL | Invalidation | Format | Cache Key |
|---|---|---|---|---|
| Team crests | 7 days | On logo change | SVG + PNG fallback | Team ID + version |
| Player photos | 24 hours | On photo update | WebP + JPEG fallback | Player ID + version |
| League logos | 30 days | On logo change | SVG + PNG fallback | League ID + version |
| Highlight clips | 24 hours | Never (immutable) | HLS (adaptive bitrate) | Clip ID |
| JavaScript bundles | 1 year | Deploy-time | ES modules | Content hash |
| CSS | 1 year | Deploy-time | Minified | Content hash |
| Font files | 365 days | Never | WOFF2 | Font name + version |
C#
public class CDNAssetService
{
private readonly ICloudFront _cdn;
private readonly IS3 _s3;
public async Task<string> GetTeamCrestUrlAsync(
string teamId, int size = 128)
{
var key = $"assets/crests/{teamId}/{size}w.webp";
if (await _cdn.HeadObjectAsync(key) != null)
return _cdn.GetPublicUrl(key);
var original = await _s3.GetObjectAsync(
"sports-assets",
$"assets/crests/original/{teamId}.svg");
var optimized = await ImageProcessor.ResizeAndConvertAsync(
original.Body,
new ResizeOptions
{
Width = size,
Format = WebP,
Quality = 85,
FitMode = Contain
});
await _s3.PutObjectAsync("sports-assets", key, optimized,
new PutObjectOptions
{
ContentType = "image/webp",
CacheControl = "public, max-age=604800"
});
return _cdn.GetPublicUrl(key);
}
}
22. Monitoring, Observability & Alerting
During a World Cup Final, a 30-second score outage affects 50 million users and generates thousands of social media complaints. The monitoring system must detect anomalies within seconds and alert the on-call team immediately. We use the three pillars of observability: metrics (Prometheus/Grafana), logs (ELK), and traces (Jaeger/Tempo).
Critical Metrics
| Metric | Alert Threshold | Impact |
|---|---|---|
| Ingestion latency (P99) | > 5 seconds | Scores delayed for users |
| WebSocket fan-out latency (P99) | > 3 seconds | Users see stale scores |
| WebSocket connection failures | > 1% error rate | Users cannot connect |
| Kafka consumer lag | > 10,000 messages | Events backing up |
| Provider API response time | > 10 seconds | Data delayed or missing |
| Notification delivery latency | > 10 seconds | Stale push notifications |
| Redis memory usage | > 80% | Cache eviction risk |
| PostgreSQL replication lag | > 5 seconds | Stale read data |
| API error rate (5xx) | > 0.1% | User-facing errors |
| API latency (P99) | > 500ms | Slow page loads |
Distributed Tracing
C#
public class EventProcessingTracer
{
private readonly ITracer _tracer;
public async Task<ProcessedEvent> TraceProcessingAsync(
RawMatchEvent rawEvent)
{
using var span = _tracer.BuildSpan("process_match_event")
.WithTag("match.id", rawEvent.MatchId)
.WithTag("event.type", rawEvent.EventType.ToString())
.WithTag("provider", rawEvent.Provider)
.WithTag("sport", rawEvent.Sport.ToString())
.StartActive();
using (var validateSpan = _tracer
.BuildSpan("validate").StartActive())
{
var result = _validator.Validate(rawEvent);
validateSpan.Span.SetTag("valid", result.IsValid);
if (!result.IsValid)
return ProcessedEvent.Rejected(result.Errors);
}
using (var enrichSpan = _tracer
.BuildSpan("enrich").StartActive())
{
await _enricher.EnrichAsync(rawEvent);
enrichSpan.Span.SetTag(
"enrichments_applied", rawEvent.Enrichments.Count);
}
using (var kafkaSpan = _tracer
.BuildSpan("kafka_produce").StartActive())
{
await _producer.ProduceAsync("processed-events",
new Message<string, MatchEvent>
{
Key = rawEvent.MatchId,
Value = rawEvent
});
kafkaSpan.Span.SetTag("kafka.topic", "processed-events");
}
return ProcessedEvent.Accepted(rawEvent);
}
}
Runbook for Common Incidents
23. Security & Compliance
Threat Model
| Threat | Attack Vector | Impact | Mitigation |
|---|---|---|---|
| Data scraping | Automated API requests | Data theft, competitive intelligence | Rate limiting, bot detection, API keys |
| Score manipulation | Compromised provider feed | Wrong scores shown to millions | Multi-provider validation, anomaly detection |
| Account takeover | Credential stuffing | Fantasy team theft, account abuse | MFA, rate limiting, CAPTCHA |
| DDoS attack | Volumetric attack | Platform unavailable during major event | AWS Shield, CloudFront rate limiting |
| API key leak | Client-side exposure | Unauthorized API access | Short-lived tokens, key rotation |
| GDPR violation | Improper data handling | Regulatory fines | Data minimization, consent management |
Authentication & Authorization
C#
public class SportsApiAuthHandler : AuthenticationHandler<JwtBearerOptions>
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var token = Request.Headers.Authorization
.FirstOrDefault()?.Split(" ").Last();
if (string.IsNullOrEmpty(token))
return AuthenticateResult.Fail("No token provided");
var handler = new JwtSecurityTokenHandler();
var key = await _signingKeyProvider.GetCurrentKeyAsync();
try
{
var principal = handler.ValidateToken(token,
new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "sports-platform",
ValidateAudience = true,
ValidAudience = "sports-api",
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
}, out var validatedToken);
var claims = principal.Claims.ToDictionary(
c => c.Type, c => c.Value);
var context = new AuthenticationContext(
UserId: claims.GetValueOrDefault("sub"),
Tier: claims.GetValueOrDefault("tier", "free"),
Scopes: claims.GetValueOrDefault("scope", "")
.Split(' '));
HttpContext.Items["AuthContext"] = context;
return AuthenticateResult.Success(
new AuthenticationTicket(principal, Scheme.Name));
}
catch (SecurityTokenException)
{
return AuthenticateResult.Fail("Invalid token");
}
}
}
public class RateLimitingMiddleware
{
private readonly RequestDelegate _next;
private readonly IDatabase _redis;
private static readonly Dictionary<string, RateLimit>
TierLimits = new()
{
["free"] = new(100, TimeSpan.FromMinutes(1)),
["pro"] = new(1000, TimeSpan.FromMinutes(1)),
["enterprise"] = new(10000, TimeSpan.FromMinutes(1)),
};
public async Task InvokeAsync(HttpContext context)
{
var authCtx = context.Items["AuthContext"] as AuthContext;
var tier = authCtx?.Tier ?? "free";
var limit = TierLimits[tier];
var key = $"ratelimit:{authCtx?.UserId ?? "anon"}";
var count = await _redis.StringIncrementAsync(key);
if (count == 1)
await _redis.KeyExpireAsync(key, limit.Window);
if (count > limit.MaxRequests)
{
context.Response.StatusCode = 429;
context.Response.Headers.RetryAfter =
((int)limit.Window.TotalSeconds).ToString();
await context.Response.WriteAsJsonAsync(new
{
error = "Rate limit exceeded",
tier,
limit = limit.MaxRequests,
window = limit.Window.TotalSeconds
});
return;
}
await _next(context);
}
}
GDPR & Data Privacy
- Data minimization: We only collect data necessary for functionality. User email for auth, preferred teams for personalization, device token for notifications.
- Right to deletion: Users can delete their account and all associated data within 30 days. A background job removes data from all stores (PostgreSQL, Redis, Elasticsearch, S3).
- Data portability: Users can export their data (fantasy teams, watch history, preferences) as JSON.
- Consent management: Cookie consent, notification permission, and data processing consent are tracked per-user with timestamps.
- Data residency: EU users' data is stored in eu-west-1 (Ireland). Users in GDPR jurisdictions have stricter consent requirements.
24. API Design
The REST API follows a resource-oriented design with consistent patterns across all endpoints. All responses use a standard envelope format with pagination cursors, and the API versioning uses URL path segments (/v1/, /v2/).
API Endpoints
| Method | Endpoint | Description | Cache |
|---|---|---|---|
| GET | /v1/matches/live | All currently live matches | 5s |
| GET | /v1/matches/{id} | Match details and current score | 5s |
| GET | /v1/matches/{id}/events | Match events timeline | 5s |
| GET | /v1/matches/{id}/stats | Match statistics | 10s |
| GET | /v1/matches/{id}/lineups | Team lineups | 30s |
| GET | /v1/fixtures | Upcoming fixtures (filtered) | 60s |
| GET | /v1/fixtures/{id} | Fixture details | 60s |
| GET | /v1/leagues/{id}/standings | League table | 30s |
| GET | /v1/players/{id} | Player profile | 5min |
| GET | /v1/players/{id}/stats | Player season stats | 5min |
| GET | /v1/sports | List of supported sports | 1 hour |
| GET | /v1/leagues | List of leagues | 1 hour |
| WS | /ws/v1/stream | WebSocket streaming endpoint | N/A |
Response Envelope
JSON
{
"status": "success",
"data": {
"matchId": "match_abc123",
"sport": "football",
"status": "in_play",
"homeTeam": {
"id": "team_ars",
"name": "Arsenal",
"score": 2,
"crest": "https://cdn.example.com/crests/team_ars.svg"
},
"awayTeam": {
"id": "team_mun",
"name": "Manchester United",
"score": 1,
"crest": "https://cdn.example.com/crests/team_mun.svg"
},
"minute": 67,
"period": "2ndHalf",
"events": [
{
"eventId": "evt_001",
"type": "goal",
"minute": 12,
"team": "home",
"player": { "id": "p_saka", "name": "Bukayo Saka" },
"assist": { "id": "p_ode", "name": "Martin Odegaard" },
"description": "Right-footed shot from the centre of the box"
}
],
"statistics": {
"possession": { "home": 58, "away": 42 },
"shots": { "home": 15, "away": 8 },
"shotsOnTarget": { "home": 7, "away": 3 },
"corners": { "home": 8, "away": 4 },
"fouls": { "home": 9, "away": 12 }
}
},
"meta": {
"timestamp": "2026-07-12T15:30:00Z",
"cacheHit": false,
"latencyMs": 45
}
}
WebSocket Protocol
JSON
// Client to Server: Subscribe
{
"type": "subscribe",
"channel": "match:abc123:events"
}
// Client to Server: Subscribe to stats
{
"type": "subscribe",
"channel": "match:abc123:stats"
}
// Client to Server: Heartbeat
{
"type": "ping"
}
// Server to Client: New goal event
{
"type": "event",
"channel": "match:abc123:events",
"data": {
"eventId": "evt_456",
"type": "goal",
"minute": 72,
"scorer": { "id": "p_havertz", "name": "Kai Havertz" },
"assist": { "id": "p_saka", "name": "Bukayo Saka" },
"team": "home",
"score": { "home": 3, "away": 1 }
}
}
// Server to Client: Stats update
{
"type": "stats_update",
"channel": "match:abc123:stats",
"data": {
"possession": { "home": 61, "away": 39 },
"shots": { "home": 18, "away": 9 },
"lastUpdated": "2026-07-12T16:12:00Z"
}
}
// Server to Client: Keepalive
{
"type": "pong",
"ts": "2026-07-12T16:12:30Z"
}
25. Cost Estimation
Costs vary dramatically between off-season (minimal live matches) and peak events (World Cup). We design for peak capacity but use auto-scaling to reduce costs during quiet periods.
Monthly Cost Breakdown (Peak Period - World Cup)
| Service | Specification | Monthly Cost |
|---|---|---|
| Kafka (MSK) | 6 brokers, 500K msgs/sec | ,200 |
| PostgreSQL (RDS) | db.r6g.2xlarge, Multi-AZ, 2TB | ,800 |
| Redis (ElastiCache) | Cluster mode, 6 shards, r6g.xlarge | ,600 |
| Elasticsearch | 6 nodes, r6g.xlarge.elasticsearch | ,200 |
| WebSocket Gateways | 500 instances (c6g.2xlarge) | ,000 |
| REST API Servers | 50 instances (c6g.xlarge) | ,000 |
| Event Processing | 100 instances (c6g.xlarge) | ,000 |
| CloudFront CDN | 50TB transfer/month | ,250 |
| S3 Storage | 50TB (assets, analytics) | ,150 |
| SQS (Notifications) | 500M messages/month | |
| FCM/APNs (Push) | 2B notifications/month | |
| Data Provider Fees | Sportradar + Opta + custom | ,000 |
| Monitoring (Datadog) | Full observability stack | ,000 |
| DNS (Route53) | High-volume queries | |
| WAF & Shield | DDoS protection | ,000 |
| Lambda/Serverless | Notification batching, image processing |
Cost Summary
| Scenario | Monthly Cost | Annual Cost |
|---|---|---|
| Off-season (quiet) | ~,000 | - |
| Normal season | ~,000 | - |
| Peak (World Cup) | ~,000 | - |
| Blended Annual | - | ~,000 |
26. Testing Strategy
Test Pyramid
50 tests
~15 min] --> INT[Integration Tests
200 tests
~10 min] INT --> UNIT[Unit Tests
2000 tests
~3 min] style E2E fill:#f85149,color:#fff style INT fill:#d29922,color:#fff style UNIT fill:#3fb950,color:#fff
Load Testing Scenarios
| Scenario | Target | Success Criteria |
|---|---|---|
| WebSocket connections | 50M concurrent | All connected within 5 minutes, <0.1% failure |
| WebSocket messages/second | 1M msgs/sec | P99 latency < 3 seconds end-to-end |
| REST API throughput | 100K requests/sec | P99 latency < 200ms |
| Ingestion throughput | 10K events/sec | All events processed within 2 seconds |
| Notification delivery | 50M notifications in 30s | 95% delivered within 5 seconds |
| Database failover | Primary down | Read serving within 30s, write recovery within 2 min |
| Kafka broker failure | 2 of 6 brokers down | All events processed within 10 seconds |
Integration Test Example
C#
[TestClass]
public class LiveMatchIntegrationTests
{
private TestContainersFixture _containers;
private HttpClient _api;
private WebSocketClient _ws;
[TestInitialize]
public async Task Setup()
{
_containers = new TestContainersFixture(
"docker-compose.test.yml");
await _containers.StartAsync();
_api = new HttpClient
{
BaseAddress = new Uri("http://localhost:5001")
};
}
[TestMethod]
public async Task Should_Update_Score_Via_WebSocket()
{
_ws = new WebSocketClient(
"ws://localhost:5001/ws/v1/stream");
await _ws.ConnectAsync();
var matchId = "test_match_001";
await _ws.SendAsync(new
{
type = "subscribe",
channel = $"match:{matchId}:events"
});
var goalEvent = new
{
matchId,
eventType = "goal",
minute = 23,
teamId = "home",
playerId = "player_001",
provider = "test"
};
var response = await _api.PostAsJsonAsync(
"/api/v1/ingest/events", goalEvent);
Assert.AreEqual(HttpStatusCode.Accepted,
response.StatusCode);
var message = await _ws.WaitForMessageAsync(
TimeSpan.FromSeconds(5));
Assert.IsNotNull(message);
Assert.AreEqual("event", message.Type);
Assert.AreEqual("goal",
message.Data.GetProperty("type").GetString());
await Task.Delay(500);
var match = await _api.GetFromJsonAsync<MatchDto>(
$"/api/v1/matches/{matchId}");
Assert.AreEqual(1, match.HomeScore);
}
[TestMethod]
public async Task Should_Batch_Multiple_Goals()
{
var matchId = "test_match_002";
for (int i = 0; i < 3; i++)
{
await _api.PostAsJsonAsync(
"/api/v1/ingest/events", new
{
matchId,
eventType = "goal",
minute = 10 + i * 5,
teamId = "home",
playerId = $"player_{i:D3}",
provider = "test"
});
await Task.Delay(1000);
}
await Task.Delay(6000);
var notifications = await GetNotificationsAsync(matchId);
Assert.AreEqual(1, notifications.Count);
Assert.IsTrue(notifications[0].Body.Contains("3 Goals"));
}
[TestCleanup]
public async Task Cleanup()
{
_ws?.Dispose();
await _containers.StopAsync();
}
}
27. Interview Q&A Deep Dive
Q1: How do you handle 50 million concurrent WebSocket connections?
Answer: Each WebSocket gateway node handles ~50K connections (limited by file descriptors and memory). With 50M connections, we need 1,000 gateway nodes. However, the key optimization is connection sharing — most users subscribe to the same 10-20 popular matches. We use Redis Pub/Sub as the fan-out backbone: one message per match is published to Redis, and all gateway nodes subscribed to that match receive it and fan out to their local connections. This means we publish 1 message per event per match, not 50M messages. The gateway nodes are distributed across 3 regions (US, EU, APAC) using latency-based DNS routing. Each region has its own Redis cluster, with cross-region replication for failover. Auto-scaling groups track connection count per node and scale out when utilization exceeds 70%.
Q2: How do you ensure score accuracy when receiving data from multiple providers?
Answer: We use a priority-weighted voting system. Each provider is assigned a priority level (Sportradar=1, Opta=2, ESPN=3). When two providers report the same event within a 10-second window, we deduplicate using a composite key (match_id, event_type, minute, team_id). When providers disagree (e.g., Sportradar says 2-1 but Opta says 2-0), we take the higher-priority provider's score but flag the discrepancy for human review. We also run a periodic reconciliation job that compares scores across providers every 60 seconds for live matches. If a provider's data deviates from consensus, we down-weight their priority. The system has a "provider health dashboard" that tracks accuracy metrics per provider per sport per league.
Q3: How do you handle a sudden traffic spike during a World Cup Final?
Answer: The World Cup Final generates 50M+ concurrent connections. Pre-scaling is essential — we don't rely on auto-scaling to react in time. Before the match, we: 1) Pre-warm all WebSocket gateway instances to maximum capacity. 2) Pre-populate Redis caches with fixture data, team info, and player profiles. 3) Scale Kafka partitions to handle 10x normal throughput. 4) Activate CloudFront Shield Advanced and WAF rules. During the match, we monitor real-time metrics and proactively scale if connection growth exceeds predictions. For worst-case scenarios, we have a "graceful degradation" mode that reduces update frequency (every 5 seconds instead of real-time) to reduce backend load by 80% while keeping the platform responsive.
Q4: How do you design the event sourcing model for match state?
Answer: The current match state (score, cards, substitutions) is always derived by replaying events from kickoff. This provides: 1) Complete audit trail — any discrepancy can be traced to the exact event. 2) Time travel — we can reconstruct the state at any point in the match. 3) Easy debugging — if the score is wrong, we find the offending event and replay from there. The state is cached in Redis for fast reads and persisted to PostgreSQL for durability. When a new event arrives, we validate it against the current state (e.g., can't have a 4th substitution in normal time), apply it, update the cache, and publish to the fan-out layer. The event store is immutable — events are never deleted or modified, only appended.
Q5: How do you handle late-arriving data (e.g., VAR decisions)?
Answer: VAR decisions can reverse events minutes after they occur. Our system handles this through an "event correction" mechanism: 1) When a VAR decision reverses a goal, a new event of type GoalDisallowed is emitted, referencing the original GoalEvent. 2) The match state processor applies the correction: score decrements, the original goal event is marked as "reversed", and the timeline shows the reversal. 3) Clients receive a state_correction WebSocket message with the corrected state. 4) Push notifications for the original goal cannot be un-sent, so we send a follow-up correction notification: "VAR Decision: Goal by [Player] has been disallowed. Score updated to 1-0."
Q6: How do you handle sport-specific scoring rules within a unified platform?
Answer: We use the Strategy pattern with a sport module registry. Each sport implements ISportModule with sport-specific event processing, statistics calculation, and match completion rules. The core platform doesn't know or care that cricket has innings while tennis has sets. It just calls the registered sport module for each operation. This makes adding a new sport a matter of implementing one interface and registering it — no changes to core code. The sport module also defines which data providers map to which event types, so the ingestion layer is sport-agnostic.
Q7: How do you keep notification delivery under 2 seconds?
Answer: The notification pipeline runs in parallel with the WebSocket fan-out, not after it. When an event is processed, two messages are published simultaneously: one to the WebSocket fan-out topic and one to the notification topic. The notification service subscribes to the notification topic, batches by user+match for 5 seconds (configurable), and sends via FCM/APNs. The 2-second target is achievable because: 1) The notification pipeline is optimized for throughput, not correctness (best-effort delivery). 2) We use persistent connections to FCM/APNs (no TCP handshake per notification). 3) The batching window is 5 seconds, but the first notification in a batch is sent immediately without waiting.
Q8: How do you handle multiple time zones for fixture display?
Answer: All kickoff times are stored in UTC. The client converts to the user's local timezone based on their device settings or profile preference. The API returns both UTC and the user's local time. For fixture lists, we group by date in the user's timezone — a match kicking off at 11:45 PM UTC on December 31st might appear under January 1st for users in UTC+1. The calendar integration generates .ics files with VTIMEZONE components so the user's calendar app handles timezone conversion correctly.
Q9: How do you handle data provider outages without affecting users?
Answer: Defense in depth: 1) Multi-provider redundancy — at least 2 providers per sport. If Sportradar goes down, we seamlessly switch to Opta within 30 seconds (automated health checks). 2) Graceful degradation — if all providers for a sport are down, we show the last known score with a "Data may be delayed" banner. We never show stale data as current. 3) Provider health monitoring — we track response times, error rates, and data freshness for each provider. If a provider's error rate exceeds 5% in a 1-minute window, we automatically failover. 4) Cached fallback — recent fixture and standings data is cached in Redis with 5-minute TTL, so even during a complete provider outage, historical data remains available.
Q10: How do you design for global low-latency delivery?
Answer: Three-layer CDN strategy: 1) CloudFront for static assets (crests, photos, JS/CSS) with 200+ edge locations. 2) Regional WebSocket gateways (US, EU, APAC) with latency-based DNS routing. Users connect to the nearest region. 3) Redis clusters in each region for hot data, with cross-region async replication. The REST API uses CloudFront as a caching layer with dynamic origin shielding. For the WebSocket layer, the key insight is that users only care about latency for their subscribed matches, not all matches. So we partition the Redis Pub/Sub by region — a user in EU only receives updates for matches they're watching, routed through the EU gateway fleet.
Key Numbers to Remember
| Metric | Value |
|---|---|
| End-to-end latency target | < 3 seconds |
| Peak concurrent users | 50M (World Cup Final) |
| Daily active users | 200M |
| WebSocket connections per node | ~50,000 |
| Gateway nodes at peak | 500-1,000 |
| Kafka partitions per topic | 128-256 |
| Redis read latency | < 1ms (P99) |
| Deduplication window | 10 seconds |
| Notification batch window | 5 seconds |
| Provider failover time | < 30 seconds |
| Availability target | 99.99% |
| Annual infrastructure cost | ~,000 |
Pre-Interview Checklist
- Understand event sourcing and why it's ideal for match state
- Know the tradeoffs between Redis Pub/Sub and Kafka for fan-out
- Design a multi-provider ingestion pipeline with deduplication
- Explain WebSocket gateway architecture at 50M scale
- Discuss sport-specific schema design with a plugin architecture
- Know how to calculate league tables with tiebreakers and points deductions
- Understand xG (Expected Goals) and advanced football analytics
- Design a fantasy points engine that processes events in real-time
- Discuss GDPR compliance for a global sports platform
- Know how to handle VAR decisions and late-arriving data
- Understand CDN caching strategies for sports assets
- Design push notification batching to avoid notification spam
18. Social Media Integration
Social integration drives organic growth and keeps users engaged beyond scores. The platform aggregates match-related social media posts (Twitter/X, Instagram, Reddit) into a unified feed, provides sharing functionality, and allows users to react to events in real-time.
Social Features Architecture
Reaction System