By the quarterfinals of the World Cup my tracker had been returning a 500 on every page for two days, and I did not know yet.
worldcup.spotmind.app is a small live match-centre. The setup was ordinary: a Next.js frontend on Vercel, and a separate Fastify backend that read ESPN's public feed, cached it, and pushed live updates over a WebSocket. The backend ran on Railway, on a free trial. The trial ended, Railway stopped the service, and because the service was gone rather than merely broken, the failure was quiet in the worst way.
Here is what made it slow to spot. The frontend was fine. Vercel served pages. The pages just could not render, because every server render fetched match data from the backend, and the backend now answered every request with a 200 status and a body that said "Application not found". That is Railway's edge telling you the app behind this URL no longer exists: a 200 with an error message sitting inside it. My code asked for a list of fixtures, got an object with a message field instead of an array, and crashed trying to map over matches that were not there.
The wrong thing to fix
The instinct is to find another free host. Fly, Render, Koyeb, pick one, redeploy the Fastify app, point the frontend at the new URL. I started down that road and stopped, because this was the second time a host had taken the site down. The first was a deploy drift between two branches weeks earlier. Both outages had the same shape: two things that had to agree, and one of them quietly stopped agreeing.
So the fix was not a better backend host. It was to not have a separate backend.
The backend did not need to be a backend
The useful thing I noticed was that the data layer had no idea it lived inside Fastify. The file that mattered, lib/espn.ts, imported nothing from the web framework. It used fetch, an in-memory cache with a short time-to-live, and a normalizer that turned ESPN's shape into mine. Fastify was only the thing wrapping it in routes.
Next.js can host exactly that. I moved lib/espn.ts into the app as a server-only module and rebuilt each backend route as a Next route handler. Twelve of them: scoreboard, schedule, standings, bracket, one match, one team, one player, and the rest. Each handler is four lines that call the same function the Fastify route used to call. The id validation and the error shapes came across unchanged, so the browser could not tell anything had moved.
Two decisions took actual thought.
Killing the WebSocket
The live match view used a WebSocket. The backend polled ESPN every 10 seconds and pushed a full match snapshot to every connected browser. Serverless functions cannot hold a socket open, so that pattern died the moment I moved in.
I replaced it with the browser polling a normal route every 10 seconds. This felt like a step backwards, and there is a real irony to it, because I have a whole post about ripping polling out of a different project and letting apps push instead. But the WebSocket was already sending the entire match object on every tick, not a diff. A poll returns the same payload the socket used to push, the code that applies it did not change one line, and a football match updates about as fast as a 10-second poll can show anyway. The elegant version bought nothing here.
Not fetching myself
The subtle one. My server components used to fetch the backend over HTTP. The lazy port is to keep doing that and point the fetch at the app's own public address. It works locally and it looks clean.
It is also a trap on Vercel. Deployment protection can put an auth wall in front of a deployment URL, and a server function calling its own public address can get a 401 login page instead of data. So server code now calls the data-layer functions directly, in the same process, and only the browser talks to /api. No round trip, nothing to 401.
The parts that bit
Two routes build the scorer and player tables by aggregating across every played match. On a cold serverless instance that fan-out can run past the default 10-second function limit, so those two carry a 60-second budget. Nothing else needed it.
The deploy itself cost me an hour. I pushed to the branch I thought was production, watched the old broken version stay live, and eventually noticed my push had only produced a preview. Production tracked a different branch. Obvious in hindsight, invisible while the site is still down and you keep refreshing it.
What it is now
One host. The tracker is a single Vercel project with no companion service to expire, drift, or fall off a trial. I checked it during a live quarterfinal: the routes served real data, the pages rendered, and the match page updated on its poll while Spain beat Belgium.
The lesson I am keeping is smaller than "serverless good, servers bad". It is that every separate thing which has to stay up is a thing that can quietly stop. If a backend can live inside the frontend without pretending to be something it is not, that is one fewer host with its own billing, its own trial clock, and its own way of disappearing without telling you.