October 28, 2026
Five Boring Bugs That Made Our Sports Site Slower, Uglier and Less Trustworthy
None of them threw an error. All of them were in production for weeks.
By Barmyman
4 min read
We run a sports analytics site: live scores, standings and AI-written previews for 25 sports, from football to cricket. The data comes from an upstream sports data provider; our side ingests it, enriches it and serves it.
Last week I spent a single day chasing small complaints — "the site feels slow", "why does this team have a grey shield instead of a logo", "this team name reads like a bad machine translation". Each one turned out to be a quiet, boring bug. None of them crashed anything or showed up in error tracking. Here they are, because I suspect most of you have at least one of them too.
1. A cron job that read the whole table 25 times every half hour
Symptom: the database sat at around five busy CPU cores for no obvious reason, and pages felt sluggish.
Cause: a search-index refresh. Matches are written straight into Postgres by the ingestion service, so a scheduled job re-indexed "the latest 5,000 matches" into the search engine every 30 minutes. It looked innocent:
SportMatch::withConsistentParticipants()
->orderByDesc('kickoff_at')->limit(5000)
->chunk(200, function ($chunk) { /* push to search */ });Lesson: “200 OK with a valid image” is not the same as “a real image”. Read the metadata your provider already sends you.SportMatch::withConsistentParticipants()
->orderByDesc('kickoff_at')->limit(5000)
->chunk(200, function ($chunk) { /* push to search */ });Lesson: “200 OK with a valid image” is not the same as “a real image”. Read the metadata your provider already sends you.chunk() turns that into 25 queries with a growing OFFSET. With our visibility filters — several JSONB conditions — the planner badly underestimated row counts and chose a parallel sequential scan plus a sort over roughly 10 million rows. For every chunk. About 35 seconds each, 25 times per run, every 30 minutes.
The worse part: "the latest 5,000 matches by kickoff" were not the latest at all. They were the 5,000 most distant fixtures, scheduled for 2027. Search had been showing next year's games instead of today's.
Fix: a date window (one week back, a month ahead), the IDs fetched once, then chunks by ID:
$ids = SportMatch::withConsistentParticipants()
->whereBetween('kickoff_at', [now()->subDays(7), now()->addDays(30)])
->orderByDesc('kickoff_at')->limit(5000)->pluck('id');
foreach ($ids->chunk(200) as $part) {
$chunk = SportMatch::with('homeTeam', 'awayTeam', 'league')
->whereIn('id', $part->all())->get();
// push to search
}$ids = SportMatch::withConsistentParticipants()
->whereBetween('kickoff_at', [now()->subDays(7), now()->addDays(30)])
->orderByDesc('kickoff_at')->limit(5000)->pluck('id');
foreach ($ids->chunk(200) as $part) {
$chunk = SportMatch::with('homeTeam', 'awayTeam', 'league')
->whereIn('id', $part->all())->get();
// push to search
}One query of about a second instead of 25 full scans. Lesson: chunk() with offsets over a filtered, ordered set is a full scan in disguise. Check the plan of the last chunk, not the first.
2. Placeholder images saved as real logos
Symptom: a user sent us a screenshot of a grey shield with the letters "QU" where a team logo should be.
Cause: when the provider has no logo, it generates one: a grey circle or shield with the team's initials. Our image fetcher saw HTTP 200 and a valid PNG, and saved it as the logo. In a sample of our files, the same placeholder file was shared by groups of 100–200 different teams.
The provider actually tells you. Every response has a header:
x-image-source: sportwire-placeholder ← generated stand-in
x-image-source: sportwire-cache ← a real imagex-image-source: sportwire-placeholder ← generated stand-in
x-image-source: sportwire-cache ← a real imageWe just never looked at it.
Fix: a placeholder is treated like "no image". It isn't stored. If we had stored an earlier placeholder from the same provider, it is removed, with the old row kept as a receipt. And we ask again in three days, because the provider keeps adding real images. Our own front-end draws a neutral badge in the meantime. In the first five minutes of the clean-up run, almost 1,800 placeholders were removed.
Lesson: "200 OK with a valid image" is not the same as "a real image". Read the metadata your provider already sends you.
3. A database trigger that silently undid human corrections
Symptom: a US college team, Pacific Boxers, was shown in Russian as a word-for-word translation: "Pacific" as the ocean adjective, "Boxers" as the fighters. We fixed the name by hand. It came back on the next sync.
Cause: months ago we added a trigger that forces every team's name to match the provider's canonical record. It was a good idea at the time: legacy translation scripts kept overwriting correct names with worse ones. But the trigger made no exception for a human editor either. Our admin panel even had a "manual" source flag for names, and the trigger quietly ignored it.
Fix: the trigger now leaves the localized name alone when its source is marked manual, and the ingestion code carries that flag through updates. Every other writer still gets the provider's name. We tested it on production data inside a rolled-back transaction: the manual name survived a full sync, while an automated write was still overridden.
Lesson: a guard that protects data from bad automation has to know the difference between automation and a person.
4. An expensive lookup recomputed for every channel, every minute
Symptom: a query costing about 1.1 seconds showed up 54 times in a 90-second window.
Cause: our social media covers try to find a player mentioned in a news headline, so the cover can show their photo. That lookup scans a year of lineups. It was called once per channel (Telegram, VK, and so on), by several scheduled jobs running every one to five minutes, for the same news story, over and over.
Fix: cache the result for six hours per story, keyed by the story and its headline. One line of code.
Lesson: when a query shows up in your top list, look at its call count before its plan. The fastest query is the one you don't run.
5. Literal machine translations nobody owns
Symptom: besides Pacific Boxers, team names like "Pacific Lutheran Lutes" and "Seattle Pacific Falcons" read in Russian like a phrasebook.
Cause: these names come from the upstream provider, which runs its own translation. Fixing that pipeline is their job, not ours. The bug on our side was #3: we had no way to override a name locally that would survive a sync.
Fix: with manual overrides now respected, we can correct the names that matter most and report the rest upstream with examples.
Lesson: decide who owns each piece of data before you need to fix it. "We'll just patch it in our database" doesn't work if your own sync is designed to undo exactly that.
What these have in common
None of these bugs produced an error. Logs were clean, monitoring was green, tests passed. Every one of them came from a user noticing something odd, or from me opening the top-queries view out of curiosity.
A few habits that would have caught them earlier:
- Look at query statistics by total time, then by call count. Bugs #1 and #4 were sitting at the top of
pg_stat_statementsfor weeks. - Count duplicates in things that should be unique. A hundred teams sharing one logo file is a signal.
- Read the response headers of every third-party API you consume. Providers often tell you more than you parse.
- Test that your safety guards allow the legitimate path, not only that they block the bad one.
- Check what a "latest N" query actually returns. Ours returned next year.
Boring bugs are the ones that stay. They don't page anyone; they just make the product a little worse every day until a user sends you a screenshot. The project that this code and article refers to is aisports.ru, project i solelly run.
Have a good one!