Skip to main content
← Back to Lab

Building a Resilient SPFL Widget Stack

The Wee Table started as a personal itch before it became an engineering exercise.

I built it for myself first as a Scottish football fan who wanted a better way to follow the SPFL. Outside Scotland, these leagues often get very little product attention, so the goal shifted quickly: build something reliable that would also help other Scottish football fans.

Sports data feeds are messy, inconsistent, and occasionally unavailable. If the product depends on one source behaving perfectly, the user experience eventually breaks.

So I treated data resilience as a product feature, not a backend detail.

The architecture goal

Keep the widget fast and useful even when upstream providers fail.

To do that, I split concerns clearly:

  • Cloudflare Worker for source aggregation and fallback logic
  • Shared SPFLWidgetKit package for domain models, API access, caching rules, and formatting
  • SwiftUI app + WidgetKit extension consuming the same shared service layer

This kept app and widget behaviour consistent while avoiding duplicated parsing and caching logic.

Source-priority ingestion

The Worker uses a strict provider-priority chain across multiple independent sources: a primary feed, a secondary fallback feed, and a final public backup feed.

When source A fails or changes shape, source B is attempted before the API returns an error path.

That simple policy reduced full-failure scenarios and made incident behaviour predictable.

Layered cache behaviour

Caching exists at three levels:

  • Worker KV for standings and fixtures responses
  • App Group UserDefaults cache shared between app and widget
  • File-based badge image cache for widget-safe rendering

This gave me a clean degradation path:

  • Fresh data if available
  • Stale-but-usable data if upstreams are down
  • Explicit offline/empty states if nothing is available

In practice, that made the app feel stable under real-world conditions, not just ideal network paths.

Widget constraints were real constraints

WidgetKit has refresh and rendering limits that shape engineering choices.

I learned quickly that rendering badge images naively in widgets is fragile. Pre-downloading and caching badge files produced much more reliable output than depending on runtime image fetch behaviour inside widget views.

Timeline refresh rules also forced careful decisions about what to refresh, how often, and from which cache layer.

Data normalisation work

A less obvious issue was team-name mismatch across providers.

Fixtures and standings can differ on naming conventions, abbreviations, and aliases. Normalising names and supporting alternate-name mapping was necessary to keep predictor calculations and table joins accurate.

Without this, the product looked broken even when source data technically existed.

Product-level outcome

The final system balanced:

  • Fast glanceability for widget-first usage
  • Reliability under upstream volatility
  • Maintainability through a shared Swift package

It reinforced a pattern I keep reusing: reliability decisions are UX decisions.